diff --git a/.gitignore b/.gitignore
index a6e56d75..618f45d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,3 +87,6 @@ tests/e2e/_e2e_server.log
.understand-anything/
+
+# Local working drafts — should never be committed (per topic-folder convention)
+syncs/
diff --git a/README.md b/README.md
index ab6c11c7..62d2d7aa 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-OntoBricks 0.5.0
+OntoBricks 0.7.0
Digital Twin Builder for Databricks
diff --git a/app.yaml.template b/app.yaml.template
index 9087d599..023c7ec7 100644
--- a/app.yaml.template
+++ b/app.yaml.template
@@ -19,12 +19,16 @@
# (defined in `pyproject.toml`'s optional `[project.optional-dependencies]
# lakebase = [...]`) so the Lakebase Postgres backend works in the
# deployed app even when the `database` resource is not yet bound.
-# This is what powers the runtime backend toggle in
-# **Settings → Registry Location**: the admin can switch
-# Volume ↔ Lakebase without redeploying. On a Volume-only deployment
-# the extra costs ~10MB of unused wheels but the app continues to
-# run normally — the Lakebase code paths are guarded by
-# `LakebaseAuth.is_available` and never reached.
+#
+# `--extra neo4j` installs the official `neo4j` Python driver
+# (Bolt / Cypher) so the Neo4j graph DB engine works when an admin
+# selects it in **Settings → Triple store → Global**. Same rationale
+# as Lakebase: the extra costs ~5MB of unused wheels on
+# Volume/Lakebase-only deployments but Neo4j code paths are guarded
+# by `NEO4J_AVAILABLE` and never reached.
+#
+# Both extras together power the runtime backend toggle:
+# the admin can switch Volume / Lakebase / Neo4j without redeploying.
#
# Databricks Apps sets DATABRICKS_APP_PORT automatically.
# MCP endpoint is exposed at /mcp (Streamable HTTP transport).
@@ -33,6 +37,8 @@ command:
- "run"
- "--extra"
- "lakebase"
+ - "--extra"
+ - "neo4j"
- "python"
- "run.py"
@@ -100,6 +106,17 @@ env:
# MLflow — ensure traces are persisted to the workspace tracking server.
- name: MLFLOW_TRACKING_URI
value: "${APP_MLFLOW_TRACKING_URI}"
+ # ── Neo4j (optional) ──────────────────────────────────────────
+ # Password sourced from a Databricks Apps secret resource (see
+ # `resources:` below). The `neo4j-password` resource is unbound by
+ # default; the admin binds it to a workspace secret (scope/key) in
+ # the Apps UI before selecting the Neo4j engine. When the resource
+ # is unbound, NEO4J_PASSWORD stays unset and the deployed app refuses
+ # to instantiate Neo4jStore with a clear instruction in the logs.
+ # Local dev keeps the legacy fallback to engine_config['password'].
+ # See docs/v0.6-neo4j-demo/secret-configuration.md.
+ - name: NEO4J_PASSWORD
+ valueFrom: neo4j-password
# User authorization scopes — declares which Databricks APIs the app is
# allowed to access on behalf of the logged-in user via the
@@ -126,3 +143,7 @@ resources:
description: "Unity Catalog Volume for the OntoBricks domain registry"
volume:
permission: CAN_READ_WRITE
+ - name: neo4j-password
+ description: "Databricks Apps secret holding the Neo4j Bolt password. Unbound by default; bind it to a workspace secret (scope/key) in the Apps UI before activating the Neo4j engine. See docs/v0.6-neo4j-demo/secret-configuration.md."
+ secret:
+ permission: READ
diff --git a/changelogs/v0.5.0/hourdays_2026-06-09.log b/changelogs/v0.5.0/hourdays_2026-06-09.log
new file mode 100644
index 00000000..114fe2b1
--- /dev/null
+++ b/changelogs/v0.5.0/hourdays_2026-06-09.log
@@ -0,0 +1,59 @@
+# 2026-06-09 — Neo4j graph DB engine
+
+**Author:** Hugues Journeau (@hourdays)
+**Branch:** `feature/neo4j-graphdb-skeleton` → PR #47 (target: `develop`)
+**Version:** v0.5.0
+
+## Context
+
+Adds **Neo4j (Bolt / Cypher)** as a selectable graph DB engine alongside Lakebase Postgres, following `docs/graphdb-integration.md`. Cleanly opt-in: existing Lakebase deployments are unaffected; users activate Neo4j by selecting it from **Settings → Triple store → Global**.
+
+This implementation realises the v0.6 roadmap slot ahead of the August 2026 target, building on Benoit's v0.5 `GraphDBBackend` abstraction and the `_starter_kit/` template.
+
+## Changes
+
+1. **`src/back/core/graphdb/neo4j/__init__.py`** — new package init with `NEO4J_AVAILABLE` guarded import.
+2. **`src/back/core/graphdb/neo4j/Neo4jStore.py`** — full `GraphDBBackend` implementation:
+ - Capability flags: `supports_cypher=True`, `supports_graph_model=False` (flat triple model in v1), `query_dialect="cypher"`.
+ - Connection management: lazy `neo4j.GraphDatabase.driver(...)`, session-per-query via `_run`.
+ - Auth: `basic` (username/password) implemented; `databricks_secret` validated but resolution deferred to a follow-up PR.
+ - CRUD: `create_table` (SPO unique constraint), `drop_table` (constraint + nodes), `insert_triples` (`UNWIND` + `MERGE`, batched), `delete_triples`, `query_triples`, `count_triples`, `table_exists` (via `SHOW CONSTRAINTS`), `get_status`.
+ - `execute_query` raises `NotImplementedError` by design — no raw Cypher entry point. Preserves the C2 safeguard ("l'entrée se fait par l'ontologie", Benoit 20/05).
+ - All 16 named-query methods (`get_aggregate_stats`, `get_type_distribution`, `get_predicate_distribution`, `find_subjects_by_type`, `resolve_subject_by_id`, `get_entity_metadata`, `get_triples_for_subjects`, `get_predicates_for_type`, `paginated_triples`, `paginated_count`, `bfs_traversal`, `find_seed_subjects`, `find_subjects_by_patterns`, `expand_entity_neighbors`, `transitive_closure`, `symmetric_expand`, `shortest_path`, `delete_cohort_triples`) implemented in native Cypher with parameterised queries.
+3. **`src/back/core/graphdb/GraphDBFactory.py`** — `_create_neo4j` dispatch + `NEO4J_AVAILABLE` guarded import + class-level availability flag.
+4. **`src/back/objects/session/GlobalConfigService.py`** — `ALLOWED_GRAPH_ENGINES = ("lakebase", "neo4j")`.
+5. **`src/back/core/reasoning/SWRLFlatCypherTranslator.py`** — new translator class scaffolded with the same public interface as `SWRLSQLTranslator`. **Methods return `None` and log a warning** — full SWRL → Cypher translation is its own follow-up PR. Reasoning on Neo4j therefore reports zero violations / inferences (graceful no-op) rather than crashing.
+6. **`src/front/config/menu_config.json`** — new "Neo4j" item under TRIPLE STORE group.
+7. **`src/front/templates/settings.html`**:
+ - `Neo4j (Bolt) ` in `#graphEngineSelect`.
+ - New `#neo4j-section` with config form: URI, database, auth method, credentials, encrypted toggle.
+8. **`pyproject.toml`** — `[project.optional-dependencies] neo4j = ["neo4j>=5.0"]`.
+9. **`tests/units/graphdb/test_neo4j_store.py`** — new test module (driver-mocked) covering capability flags, construction validation, schema sanitisation, CRUD Cypher emission, factory dispatch, and reasoning translator wiring.
+
+## Files modified / added
+
+- `src/back/core/graphdb/neo4j/__init__.py` (new)
+- `src/back/core/graphdb/neo4j/Neo4jStore.py` (new, ~580 lines)
+- `src/back/core/graphdb/GraphDBFactory.py`
+- `src/back/objects/session/GlobalConfigService.py`
+- `src/back/core/reasoning/SWRLFlatCypherTranslator.py` (new)
+- `src/front/config/menu_config.json`
+- `src/front/templates/settings.html`
+- `pyproject.toml`
+- `tests/units/graphdb/__init__.py` (new)
+- `tests/units/graphdb/test_neo4j_store.py` (new)
+
+## Known limitations (deliberate)
+
+- **Reasoning on Neo4j is a no-op** until the dedicated SWRLFlatCypherTranslator translation PR lands. UI surfaces zero violations / zero inferences cleanly.
+- **`auth_method=databricks_secret`** is validated but unresolved — basic auth is the only live-tested path.
+- **`paginated_triples` SQL conditions** are not translated to Cypher — the unfiltered page is returned and the call is logged. Filtered access should switch to `find_subjects_by_type` / `find_seed_subjects`.
+- **`settings.js` save handlers** for the Neo4j section are not in this commit — `engine_config` can currently be persisted via API; UI wiring follows in the next commit on this branch.
+- **Build page** (`_query_sync.html` / `_domain_validation.html`) — engine-aware "Graph DB (…)" labels for Neo4j follow in the next commit on this branch.
+
+## Test result
+
+- Static syntax check on all new files: OK.
+- Unit tests pass when `neo4j>=5.0` is installed; skip cleanly when not.
+- Live smoke test against the Ryan-provisioned Aura (`neo4j+s://b4810af7.databases.neo4j.io`) — pending after the next commit (settings.js wiring + Build page labels).
+- `make test` — to be re-run before marking the PR ready-for-review.
diff --git a/changelogs/v0.5.0/hourdays_2026-06-12.log b/changelogs/v0.5.0/hourdays_2026-06-12.log
new file mode 100644
index 00000000..e5033a64
--- /dev/null
+++ b/changelogs/v0.5.0/hourdays_2026-06-12.log
@@ -0,0 +1,69 @@
+# 2026-06-12 — Build page engine label: API fallback when dt.graph_engine is empty
+
+**Author:** Hugues Journeau (@hourdays)
+**Branch:** `feature/neo4j-graphdb-skeleton` → PR #47 (target: `develop`)
+**Version:** v0.5.0
+
+## Context
+
+Follow-up to commit `5205010` (engine-aware Build page label). The previous
+patch read `dt.graph_engine` from the `/dtwin/exist` payload, but that field
+is only populated **after** a domain has been built. Pre-Build (status
+"Never built"), `dt.graph_engine` is empty and the JS fallback
+`dt.graph_engine || 'lakebase'` mislabels the card as "Graph DB (Lakebase)"
+even when the global engine setting is Neo4j.
+
+This patch fetches `/settings/graph-engine` asynchronously when
+`dt.graph_engine` is empty and re-applies the title + Lakebase-details
+visibility once the global engine is known.
+
+## Changes
+
+1. **`src/front/static/domain/js/domain-validation.js`** — in
+ `populateDtwinCard()` (Build page validation card), when
+ `dt.graph_engine` is falsy, kick off a `fetch('/settings/graph-engine')`
+ and re-apply `psDtGraphBackendTitle` / `psDtLakebaseDetails` from the
+ resolved global engine. Initial render keeps the existing
+ `'lakebase'` fallback so there is no visual flicker for users on
+ Lakebase.
+2. **`src/front/static/query/js/query-sync.js`** — same pattern in
+ `_applyBuildGraphEngineUi()`, gated on both `dt.graph_engine` AND
+ `cfg.graph_engine` being absent (avoids redundant fetches when the
+ value is already cached on `window.__TRIPLESTORE_CONFIG`). On success,
+ caches the resolved engine onto `cfg.graph_engine` for subsequent
+ calls.
+
+## Stronger JS reconciliation (2026-06-12 PM)
+
+Server fix landed in `dependencies.py` but `dt.graph_engine` can still arrive
+stale: it reflects the engine recorded on the domain at build-time, which
+isn't necessarily the active global engine. Updated both JS files to
+reconcile unconditionally against `/settings/graph-engine` on every render
+(was: only when `dt.graph_engine` was empty). Global engine is now the
+single source of truth for the Build/Validation Graph DB card title.
+
+## Server-side companion fix
+
+3. **`src/front/fastapi/dependencies.py`** — root-cause fix for
+ `triplestore_page_context`. The previous body was a tautology
+ (`graph_engine = _raw if _raw == "lakebase" else "lakebase"`) that
+ silently coerced any non-Lakebase engine to `"lakebase"` before it
+ reached the template, so the `__TRIPLESTORE_CONFIG.graph_engine`
+ variable in `domain.html` / `dtwin.html` was hard-stuck on Lakebase
+ even when Neo4j was the active global engine. Replaced with a direct
+ pass-through of `TripleStoreFactory._resolve_graph_engine(...)`. The
+ JS fetch fallback above remains in place as defence in depth.
+
+## Files modified / added
+
+- `src/front/static/domain/js/domain-validation.js`
+- `src/front/static/query/js/query-sync.js`
+- `src/front/fastapi/dependencies.py`
+
+## Test result
+
+- Static syntax check on both files: OK.
+- Live behaviour to be verified in the Chrome MCP screenshot capture
+ pass (task #54 in the PR plan) — the Build page should now display
+ "Graph DB (Neo4j)" pre-Build when the global engine is Neo4j on the
+ fevm-mjolnir deployment.
diff --git a/changelogs/v0.5.0/hourdays_2026-06-22.log b/changelogs/v0.5.0/hourdays_2026-06-22.log
new file mode 100644
index 00000000..35e3bf17
--- /dev/null
+++ b/changelogs/v0.5.0/hourdays_2026-06-22.log
@@ -0,0 +1,92 @@
+## Move Neo4j Bolt password to a Databricks Apps secret resource
+
+**Context:** Benoit's PR #47 review on 2026-06-18 flagged that the Neo4j password was being persisted in clear inside `global_config.graph_engine_config` — a blocker for any customer deployment. Switching to a Databricks Apps **secret resource** declared in `app.yaml` and injected as the `NEO4J_PASSWORD` env var at runtime; the persisted JSON `password` is stripped at save-time, so no clear-text credential ever lands in the database. Local development keeps the existing `engine_config.password` fallback (guarded by `DATABRICKS_APP_PORT` to detect the deployed environment).
+
+**Changes:**
+1. `src/back/core/graphdb/neo4j/Neo4jStore.py` — `_resolve_auth()` now reads `NEO4J_PASSWORD` env var first (logged as source); in the deployed app (`DATABRICKS_APP_PORT` set) the env var becomes mandatory and a missing variable raises `InfrastructureError` with a clear remediation pointer. Local-dev path unchanged (`engine_config.password`). New module-level helper `is_neo4j_password_from_secret()` exposes the credential source to other layers. Switched `ValueError` → `ValidationError` on the user-input validation branches (aligned with `back.core.errors` hierarchy from `src/.coding_rules.md §4`). Constant `NEO4J_PASSWORD_ENV` added.
+2. `app.yaml.template` — declared the `neo4j-password` resource (`secret: permission: READ`) and the `NEO4J_PASSWORD` env var with `valueFrom: neo4j-password`. The resource is **Unbound** by default; the admin binds it via the Apps UI to a workspace secret (scope/key) before activating the Neo4j engine.
+3. `src/back/objects/domain/SettingsService.py` — `set_graph_engine_config_result()` strips `password` from the incoming `engine_config` dict whenever `NEO4J_PASSWORD` is set in the environment. Defence-in-depth: even if the UI sends a password, the persisted JSON never contains it.
+4. `src/front/routes/home.py` — settings page context now exposes `neo4j_password_from_secret` so the Jinja template can render the credential-source badge.
+5. `src/front/templates/settings.html` — Neo4j settings form: password label gains a dynamic badge (`From Apps secret` green / `Local-dev fallback` yellow) and the input is `disabled` with a `••••••••` placeholder when the secret is bound. Help text below points to the new docs page.
+6. `src/front/static/config/js/settings.js` — `mergeNeo4jPanelIntoConfigTextarea()` never serialises the password field when the input is disabled (Apps secret in place), preventing stale clear-text values from being re-sent on save.
+7. `docs/pr47-neo4j-demo/secret-configuration.md` (new) — step-by-step admin guide: `databricks secrets put-secret` → bind the `neo4j-password` resource → verify in Settings UI → troubleshooting table.
+8. `tests/units/graphdb/test_neo4j_store.py` — new `TestPasswordSourcing` class with 7 unit tests covering helper behaviour and every branch of `_resolve_auth` (env-var-wins, local-dev fallback, prod refusal, missing creds, missing username).
+
+**Modified files:**
+- `src/back/core/graphdb/neo4j/Neo4jStore.py`
+- `app.yaml.template`
+- `src/back/objects/domain/SettingsService.py`
+- `src/front/routes/home.py`
+- `src/front/templates/settings.html`
+- `src/front/static/config/js/settings.js`
+- `docs/pr47-neo4j-demo/secret-configuration.md` (new)
+- `tests/units/graphdb/test_neo4j_store.py`
+
+**Test result:** `python3 -m py_compile` on every changed `.py` — OK. `node --check` on `settings.js` — OK. Jinja2 lex of `settings.html` — OK. Inline logic test of the 8 `_resolve_auth` + helper paths via stubs — all assertions pass. Full `pytest tests/units/graphdb/test_neo4j_store.py::TestPasswordSourcing` deferred (uv environment offline locally; will run in CI).
+
+## Log the executed Cypher at INFO (PR #47 review – Benoit 2026-06-18)
+
+**Context:** Benoit's PR #47 review asked that every Cypher statement run by the Neo4j backend be visible in the Databricks app logs at INFO level — without leaking secrets — so operators can correlate UI actions (filters, builds, GraphQL resolvers) with the backend query. Bolt-bound parameters are kept at DEBUG only (they don't carry credentials — auth lives on the driver, not the session — but they may carry URIs/literals from the build pipeline that don't belong in default INFO logs). Single-line, whitespace-flattened, truncation-marked snippets so logs stay grep-friendly.
+
+**Changes:**
+1. `src/back/core/graphdb/neo4j/Neo4jStore.py` — `_run()` now emits one INFO log line per call: `"Cypher ( rows, ms): "`. Bound `params` go to DEBUG. Added module-level helper `_normalise_cypher_for_log()` that collapses runs of whitespace to single spaces and truncates beyond `_CYPHER_LOG_MAX` (1500 chars) with a `"… (truncated)"` marker — caps log line size without dropping context. New imports: `re`, `time`.
+2. `tests/units/graphdb/test_neo4j_store.py` — new `TestCypherLogging` class with 3 unit tests: whitespace flattening, long-cypher truncation, and end-to-end verification that `_run` emits the expected INFO line with row count, duration, flattened cypher, and **no leakage of bound parameter values** into INFO logs.
+
+**Modified files:**
+- `src/back/core/graphdb/neo4j/Neo4jStore.py`
+- `tests/units/graphdb/test_neo4j_store.py`
+
+**Test result:** `python3 -m py_compile` — OK. Inline run of `_run` against a fake driver confirmed: 1 INFO line emitted, row count + duration + flattened multi-line cypher present, bound `$s = 'ex:value'` param **absent** from the INFO log. Truncation + whitespace-flattening helpers verified. Full pytest suite deferred to CI (uv offline locally).
+
+## Fix Settings page engine-selector flash (PR #47 review – Benoit 2026-06-18)
+
+**Context:** Benoit's review caught a UX flicker on the Settings page: the *Triple store > Global > Graph DB Engine* selector painted `Lakebase` (the first ``) for a fraction of a second on every page load before the lazy JS fetch on `/settings/graph-engine` came back and replaced the value with `Neo4j`. Fix is to resolve the persisted engine **server-side** during the page render and emit the correct `selected` attribute on the right ` ` so the first paint already matches the persisted state. Also align the Lakebase sub-panel visibility (`applyGraphDbEnginePanels()`) before the lazy fetch so its display state doesn't flicker either.
+
+**Changes:**
+1. `src/front/routes/home.py` — settings page route now depends on `Settings` and calls `SettingsService.get_graph_engine_result(session_mgr, settings)` to resolve the persisted engine before rendering. Failure is non-fatal: a warning is logged and the template falls back to `"lakebase"` (matches the previous behaviour, so no regression in degraded mode).
+2. `src/front/templates/settings.html` — ` ` / ` ` gain a `{% if graph_engine == "" %}selected{% endif %}` clause; the `` also carries a `data-server-rendered-engine="{{ graph_engine|default('lakebase') }}"` attribute for future JS reconciliation if needed.
+3. `src/front/static/config/js/settings.js` — the `DOMContentLoaded` init block now calls `applyGraphDbEnginePanels()` once at start so the Lakebase sub-panel's visibility matches the server-rendered engine selector before any async fetch runs.
+
+**Modified files:**
+- `src/front/routes/home.py`
+- `src/front/templates/settings.html`
+- `src/front/static/config/js/settings.js`
+
+**Test result:** `python3 -m py_compile` on `home.py` — OK. `node --check` on `settings.js` — OK. Jinja2 `env.lex` on `settings.html` — 347 tokens parsed cleanly, both `{% if graph_engine == "..." %}selected{% endif %}` markers present, `data-server-rendered-engine` attribute present. Functional verification (no flash) deferred to the deployed-app smoke test (#73).
+
+## Split Neo4jStore.py into 4 cohesive modules (PR #47 review – Benoit 2026-06-18)
+
+**Context:** Benoit's review flagged that `Neo4jStore.py` had grown to 1028 LoC mixing four orthogonal concerns: driver/auth lifecycle, schema, bulk writes, and read queries. Per the project's Fowler-vocabulary convention (`src/.coding_rules.md §9`) this is **Large Class → Extract Class**. The split produces three new services held by a thin Store façade; the public ``Neo4jStore`` API is unchanged so the factory and all callers continue to work.
+
+**Fowler refactorings applied:**
+1. **Extract Class** — driver lifecycle, auth resolution, and the single ``run()`` execution path become :class:`Neo4jConnection` (`Neo4jConnection.py`). Owns ``_resolve_auth``, ``_is_deployed_app``, ``get_driver``, ``close``, ``run``, and the Cypher-logging helpers (``_normalise_cypher_for_log``, ``_CYPHER_LOG_MAX``, ``_WHITESPACE_RE``). The ``NEO4J_PASSWORD_ENV`` constant and the ``is_neo4j_password_from_secret`` module helper live here too.
+2. **Extract Class** — schema + bulk writes become :class:`Neo4jWriteOps` (`Neo4jWriteOps.py`). Owns ``create_table``, ``drop_table``, ``insert_triples``, ``delete_triples``, ``optimize_table``, ``delete_cohort_triples``. Module-level helper ``sanitise_label`` (used by every op to derive the Cypher node label from a table name).
+3. **Extract Class** — read queries become :class:`Neo4jReadOps` (`Neo4jReadOps.py`). Owns the 16+ named-query methods: statistics (``get_aggregate_stats``, ``get_type_distribution``, ``get_predicate_distribution``), entity lookup (``find_subjects_by_type``, ``resolve_subject_by_id``, ``get_entity_metadata``, ``get_triples_for_subjects``, ``get_predicates_for_type``), pagination (``paginated_triples``, ``paginated_count``), the KG-filter primitives Benoit asked about (``bfs_traversal``, ``find_seed_subjects``, ``find_subjects_by_patterns``, ``expand_entity_neighbors``), and the reasoning helpers (``transitive_closure``, ``symmetric_expand``, ``shortest_path``).
+4. **Façade pattern** — ``Neo4jStore`` becomes a thin composition holding the three services and exposing the unchanged ``GraphDBBackend`` interface. Every method is a one-line delegator. Back-compat: keeps ``_run`` (passthrough to ``Neo4jConnection.run``), ``_resolve_auth`` (delegate), ``_is_deployed_app`` (staticmethod delegate), and a ``_driver`` property whose setter writes through to ``self._connection._driver`` (preserves tests that swap in a fake driver).
+
+**Public API preservation:**
+- All ``Neo4jStore(...)`` constructor signatures unchanged.
+- All ``TripleStoreBackend`` / ``GraphDBBackend`` methods unchanged.
+- ``__init__.py`` re-exports both the legacy symbols (``Neo4jStore``, ``is_neo4j_password_from_secret``) AND the new types (``Neo4jConnection``, ``Neo4jWriteOps``, ``Neo4jReadOps``) so external callers can pick either entry point.
+- ``Neo4jStore`` itself re-exports ``_normalise_cypher_for_log`` and the auth constants for the legacy import path.
+
+**Test updates:**
+- `tests/units/graphdb/test_neo4j_store.py` — helper ``_store(...)`` now mocks ``s._connection.run`` (the new execution layer) and aliases ``s._run`` to the same mock so existing assertions on ``s._run.call_args`` keep working unchanged. No assertion in any existing test class needed a logic change.
+- `TestCypherLogging::test_run_emits_info_log_with_cypher_and_metrics` — updated the ``caplog.at_level(logger=...)`` name from ``back.core.graphdb.neo4j.Neo4jStore`` to ``ontobricks.core.graphdb.neo4j.Neo4jConnection`` (correct post-split + accounts for ``get_logger``'s ``back.`` → ``ontobricks.`` prefix rewrite).
+
+**Modified files:**
+- `src/back/core/graphdb/neo4j/Neo4jStore.py` (was 1028 LoC → 435 LoC, all delegators)
+- `src/back/core/graphdb/neo4j/__init__.py` (re-exports the four new types + legacy symbols)
+- `src/back/core/graphdb/neo4j/Neo4jConnection.py` (new — 227 LoC)
+- `src/back/core/graphdb/neo4j/Neo4jWriteOps.py` (new — 156 LoC)
+- `src/back/core/graphdb/neo4j/Neo4jReadOps.py` (new — 614 LoC)
+- `tests/units/graphdb/test_neo4j_store.py` (mock target updated to `s._connection.run`; logger name corrected)
+
+**File-size verification (per `src/.coding_rules.md §12` expectation that each file stays < 800 LoC):**
+- `Neo4jStore.py` 435 LoC ✓
+- `Neo4jConnection.py` 227 LoC ✓
+- `Neo4jWriteOps.py` 156 LoC ✓
+- `Neo4jReadOps.py` 614 LoC ✓
+- All four files single-class, name-matches-PascalCase.
+
+**Test result:** `python3 -m py_compile` on every new/changed `.py` — OK. Inline equivalence harness (mocking `s._connection.run` + replaying each existing test class' call pattern) — all 18 assertions pass: construction errors, capability flags, sanitisation, every CRUD method (create/drop/insert/empty/count/exists/status/execute_query refusal), every named query reachable through the façade (`get_aggregate_stats`, `find_subjects_by_type`), all three `TestPasswordSourcing` env-var paths via the back-compat `_resolve_auth` delegate, `TestCypherLogging` end-to-end (fake driver → INFO log captured from the correct logger, no param leak, no truncation, whitespace flattening). Full `pytest` deferred to CI (uv environment offline locally).
diff --git a/changelogs/v0.7.0/hourdays_2026-06-25.log b/changelogs/v0.7.0/hourdays_2026-06-25.log
new file mode 100644
index 00000000..247744db
--- /dev/null
+++ b/changelogs/v0.7.0/hourdays_2026-06-25.log
@@ -0,0 +1,53 @@
+## Wire Settings → Neo4j "Test connection" button + bind neo4j-password secret resource via DAB
+
+**Context:** Smoke test of the post-review v0.7.0 build on `fevm-mjolnir` revealed two gaps that bundled cleanly into one improvement: (1) the "Test connection" button on Settings → Triple store → Neo4j was a placeholder ("Test-connection is not wired up yet") — left over from the v0.6.0 ship; (2) the `neo4j-password` Apps secret resource was declared in `app.yaml.template` but not bound to a workspace secret by the DAB, requiring manual UI binding after every deploy. This change wires the button to a real Bolt handshake **and** binds the secret resource through `databricks.yml`, so a `make deploy` lands a working Neo4j stack in one shot.
+
+**Verified live on `fevm-mjolnir`** (2026-06-25, app `ontobricks-070`):
+```
+Connected to neo4j+s://b4810af7.databases.neo4j.io (database neo4j) in 860.2 ms
+credentials from env var (NEO4J_PASSWORD — Databricks Apps secret)
+```
+
+**Changes:**
+1. `databricks.yml` — declared the `neo4j-password` secret resource on the `dev-lakebase` target overlay (`scope=ontobricks, key=neo4j-password, permission=READ`). At deploy time DAB binds the Apps resource directly to the workspace secret, so no manual UI step is needed. The standalone declaration in `app.yaml.template` (no scope/key) stays in place for customers who prefer the "admin binds via UI" flow.
+2. `src/api/routers/internal/settings.py` — new route `POST /settings/graph-engine/neo4j-test` calling `SettingsService.graph_engine_neo4j_test_result(...)`. Thin route (4 lines), delegates to the service layer.
+3. `src/back/objects/domain/SettingsService.py` — new staticmethod `graph_engine_neo4j_test_result(...)` (~115 LoC). Reads the persisted `engine_config`, constructs a `Neo4jConnection` (which resolves auth from the `NEO4J_PASSWORD` env var first, then falls back to `engine_config.password` in local dev), calls the official driver's `verify_connectivity()` (a lightweight Bolt handshake — no Cypher is executed, no data is touched), and returns one of:
+ - `{success: true, ok: true, uri, database, latency_ms, credentials_source}` — the credentials_source string surfaces *which* layer provided the password ("env var (NEO4J_PASSWORD — Databricks Apps secret)" vs "engine_config (local-dev fallback)").
+ - `{success: true, ok: false, error, category}` — clean error states with `category` in `{config, driver-missing, auth, connectivity}` so the UI can colour-code without parsing the message.
+4. `src/front/static/config/js/settings.js` — replaced the placeholder click handler on `#btnTestNeo4jConnection`. The new handler shows a spinner, persists the current form state (so the test uses what's in the form, not just what was last saved), POSTs the test endpoint, then renders a Bootstrap alert: green with latency + credentials source on success, red with a `category` badge on failure.
+
+**Files:**
+- `databricks.yml`
+- `src/api/routers/internal/settings.py`
+- `src/back/objects/domain/SettingsService.py`
+- `src/front/static/config/js/settings.js`
+- `docs/pr47-neo4j-demo/screenshots/20-settings-neo4j-secret-badge.png` (new — captures the green "From Apps secret" badge + disabled password input)
+- `docs/pr47-neo4j-demo/screenshots/22-settings-global-engine-selector.png` (new — captures the server-side-rendered engine selector with no flash)
+- `docs/pr47-neo4j-demo/screenshots/23-settings-neo4j-test-connection-result.png` (new — captures the green "Connected … 860.2 ms · credentials from env var" alert)
+
+**Test result:** `python3 -m py_compile` on every changed `.py` — OK. `node --check` on `settings.js` — OK. **Live verification on `fevm-mjolnir` / `ontobricks-070` (post deploy `bf39lra2x`):**
+- Initial test → `category: "connectivity"` clean error (Aura instance was offline at that moment — DNS unresolvable from the Databricks Apps container). UI rendered the red badge correctly.
+- After Aura came back online → `{ok: true, latency_ms: 860.2, credentials_source: "env var (NEO4J_PASSWORD — Databricks Apps secret)"}`. UI rendered the green alert correctly.
+- Apps logs (`databricks apps logs ontobricks-070`) show the expected sequence: `Neo4jConnection._resolve_auth → "Neo4j credentials sourced from NEO4J_PASSWORD env var"`, `Neo4jConnection.get_driver → "Neo4j driver opened for ..."`, `Neo4jConnection.close → "Neo4j driver closed"` — all four `db-hkyi…` / Lakebase / secret resource paths exercised cleanly.
+
+## Extend Test-connection with a `RETURN 1` Cypher probe + deck/PDF refresh
+
+**Context:** The Bolt handshake alone (`driver.verify_connectivity()`) only proves the TCP+auth layer reaches Aura. To also prove the **full Cypher path** — session creation, `_run` execution, and the new INFO log line emitted from `Neo4jConnection.run` (per item #69) — the Test-connection endpoint now follows the handshake with a trivial `RETURN 1 AS probe` query through the same `_run` code path the production query stack uses. This gives operators visible end-to-end proof on a single button click (handshake latency + Cypher echo, both surfaced in the UI alert and in the app logs).
+
+**Changes:**
+1. `src/back/objects/domain/SettingsService.py` — after `driver.verify_connectivity()`, run `conn.run("RETURN 1 AS probe")` (still inside the existing try/except so failure surfaces with `category: "connectivity"`). New `cypher_probe` field in the success payload: `{"rows": , "echo": }`.
+2. `src/front/static/config/js/settings.js` — success alert now appends "RETURN 1 AS probe echoed N row(s) — Cypher path live." when the probe is present.
+3. `docs/pr47-neo4j-demo/deck.html` — major refresh: cover banner bumped to "v0.6.0 → v0.7.0" with refreshed date metadata; TL;DR bullet added for the post-review iteration; **6 new slides** (21–26) inserted between Data Quality (slide 20) and Closing (now slide 27) covering: (a) v0.7 overview tiles for all 6 commits, (b) Secret resource flow with the `From Apps secret` badge screenshot, (c) Test-connection wire with the green `Connected … 860 ms · Cypher path live` alert screenshot, (d) Cypher INFO log format with the real prod log capture, (e) Settings flash fix before/after, (f) Modular split composition diagram. All previous `slide-page` markers renumbered 1/21–21/21 → 1/27–27/27. Closing slide updated with v0.7 bugs row, v0.7 footer, and revised "Ask: 6 commits + verified live on ontobricks-070" card.
+4. `docs/pr47-neo4j-demo/OntoBricks-PR47-Neo4j.pdf` — regenerated via headless Chrome from the updated deck.html (5.85 MB, 27 pages).
+5. `docs/pr47-neo4j-demo/screenshots/23-settings-neo4j-test-connection-result.png` — re-captured to include the new `RETURN 1 AS probe echoed 1 row(s) — Cypher path live` tail in the green alert.
+6. `docs/pr47-neo4j-demo/screenshots/24-app-logs-cypher-info-real.txt` (new) — text capture of the actual prod log sequence from a single Test-connection click on `ontobricks-070`: the 5 lines `_resolve_auth`, `get_driver`, DEBUG `Cypher params`, INFO `Cypher (1 rows, 706.4 ms): RETURN 1 AS probe`, DEBUG `close`. Used verbatim in deck slide 24.
+
+**Modified files:**
+- `src/back/objects/domain/SettingsService.py`
+- `src/front/static/config/js/settings.js`
+- `docs/pr47-neo4j-demo/deck.html`
+- `docs/pr47-neo4j-demo/OntoBricks-PR47-Neo4j.pdf`
+- `docs/pr47-neo4j-demo/screenshots/23-settings-neo4j-test-connection-result.png`
+- `docs/pr47-neo4j-demo/screenshots/24-app-logs-cypher-info-real.txt` (new)
+
+**Test result:** `python3 -m py_compile` + `node --check` — OK. Live on `ontobricks-070` (post deploy `b7net6nay`): UI alert reads `Connected to neo4j+s://b4810af7.databases.neo4j.io (database neo4j) in 1574.4 ms · credentials from env var (NEO4J_PASSWORD — Databricks Apps secret). · RETURN 1 AS probe echoed 1 row(s) — Cypher path live.` Prod log captures the matching INFO line: `Cypher (1 rows, 706.4 ms): RETURN 1 AS probe`.
diff --git a/changelogs/v0.7.0/hourdays_2026-06-26-bis.log b/changelogs/v0.7.0/hourdays_2026-06-26-bis.log
new file mode 100644
index 00000000..720e8cf7
--- /dev/null
+++ b/changelogs/v0.7.0/hourdays_2026-06-26-bis.log
@@ -0,0 +1,51 @@
+## Post-smoke-test hardening — 4 issues surfaced by the v0.7 E2E, all addressed
+
+**Context:** The 2026-06-25/26 E2E walk on `ontobricks-070` surfaced four issues that landed between "preexisting v0.6 dette" and "real v0.7 gaps". The smoke test was successful enough to ship the PR for review, but Hugues asked for a follow-up pass to make the build genuinely release-ready, not just review-ready. This commit ships fixes/docs for all four.
+
+**Issues addressed:**
+
+### #1 — GraphQL Playground returned `HTTP 400` on ontologies without properties
+
+The Settings → Triple store → Neo4j → KG view → **GraphQL Playground** rendered `Could not load GraphQL schema: HTTP 400` when an ontology had classes but no properties (or no classes at all). The 400 was technically correct from the route's POV (it raised `ValidationError`) but the UI showed only the bare HTTP code, no remediation hint.
+
+The v0.7 walk hit this because the LLM ontology generation couldn't parse the PFAS PDF (no `ai_parse_document` warehouse — see #2 below), so it produced 38 classes / 0 properties → schema gen → no fields → 400.
+
+**Fix:** Introduced `_diagnose_empty_ontology(classes, properties)` in `back/fastapi/graphql_routes.py`. Returns `(reason, message)` for the two soft-fail modes (`no_classes`, `no_properties`). Both schema routes (`/graphql/{domain}/schema` and `/dtwin/graphql/schema`) now branch on the diagnostic and return **HTTP 200 with `ready: false` + typed `reason` + stats** instead of HTTP 400. The Playground JS (`query-graphql.js`) parses the new shape and renders an in-context Bootstrap alert ("**GraphQL not ready** — ontology missing classes or properties. Ontology has N class(es), M propert(ies). Reason: ``"). The legacy `sdl` key remains for successful requests, so old callers reading just `data.sdl` still work.
+
+### #2 — `ai_parse_document` prereq not documented anywhere
+
+When the bound SQL warehouse can't run `ai_parse_document`, the LLM ontology agent's `read_document` tool returns a terse error string. The agent gracefully falls back to filename-only inference, but the user has no idea WHY their PDF wasn't read. This was the root cause of the v0.7 walk's 0-properties ontology.
+
+**Fix:** Two changes.
+
+- `src/agents/tools/documents.py:181` — extended the error JSON returned by `tool_read_document` to include a `remediation` field with two concrete steps (grant the app SP access to `system.ai`, OR re-bind the `sql-warehouse` Apps resource to a warehouse with `ai_parse_document` enabled). The message also now links to the new doc page below.
+- `docs/pr47-neo4j-demo/ai-parse-document-prereq.md` (NEW) — full troubleshooting guide: symptom checklist, the two fix paths in CLI form, verification instructions, and a "why this matters for v0.7" section explaining the v0.6-vs-v0.7 warehouse difference (Benoit's `d2096aa075ad44a3` had it, the FEVM-Mjolnir `8ea372c75c4a5251` doesn't).
+
+### #3 — `mcp-ontobricks-070` companion app deploy failed on first run
+
+The very first deploy of a fresh app pair (UI + MCP companion) had the MCP companion error with `App deployment failed unexpectedly` on the `databricks bundle run mcp_ontobricks_app` step. Subsequent deploys succeeded, suggesting an Apps-platform race during resource provisioning. (The MCP app is now happily RUNNING in prod — confirmed via `databricks apps get mcp-ontobricks-070`.)
+
+**Fix:** `scripts/deploy.sh` — the MCP-start step now retries once with a 15 s backoff before calling `die`. The retry block keeps the same exit semantics on the second failure (clean error + log inspection hint) but auto-recovers from the first-deploy race.
+
+### #4 — Auto-Map completion message conflated "chunk errors" with "no mapping generated"
+
+The auto-mapping LLM agent processes entities in chunks of 5. After a run, its completion message read e.g. `Completed: 30 entities, 0 relationships mapped (2 chunk(s) had errors)`. The "2 chunks had errors" phrasing made it sound like 2 LLM calls had crashed, but in fact what happened was: the agent ran cleanly, mapped 30 of the 38 entities, and **8 entities had no mapping generated** (legitimate — `pfas_measurements_demo` has no columns matching Monitoring Activity / Personnel / Water Body / etc.). The actual count of crashed LLM chunks was zero or close to it; the "2 chunks" figure came from a counter that included items the LLM cleanly declined to map.
+
+**Fix:** `src/back/objects/mapping/Mapping.py:341` — the completion message now distinguishes the two cases: `Completed: 30 entities, 0 relationships mapped (N chunk(s) errored, M item(s) without a generated mapping)`. Only the genuine chunk-level errors (LLM exception, agent error) feed into the "errored" tally. Items the LLM cleanly couldn't map get their own count. The result dict also now exposes `stats.chunk_errors_count` and `stats.chunk_errors` (list of error strings) for UI / debug consumers.
+
+### #5 — local pytest blocked by offline PyPI
+
+`uv` couldn't reach `pypi.org` from the dev VM during the session, blocking `pytest tests/units/graphdb/test_neo4j_store.py`. The test file itself is intact (`python3 -m py_compile` — OK, 34 `test_*` methods including the new `TestPasswordSourcing` + `TestCypherLogging`). The CI on the PR didn't trigger either — `databrickslabs/ontobricks` workflow runs require admin approval for first-time external contributors (Benoit / Dermot would need to click "Approve and run" on the Actions tab).
+
+**Action:** None on the code — the test file is correct. Documented here so a reviewer running `pytest` locally with working PyPI can verify the suite passes. The inline assertion harnesses run earlier in the session (in chat tool outputs) exercise the same scenarios via stubbed neo4j and confirmed: helper True/False/whitespace, 5 `_resolve_auth` branches, whitespace + truncation, INFO log emission with no param leak, façade delegation.
+
+**Modified files:**
+- `src/back/fastapi/graphql_routes.py` — new `_diagnose_empty_ontology` helper + friendly fallback for `/{domain_name}/schema`.
+- `src/api/routers/internal/dtwin.py` — friendly fallback for `/dtwin/graphql/schema`.
+- `src/front/static/query/js/query-graphql.js` — render the new `ready: false` payload as a Bootstrap alert with stats + reason badge.
+- `src/agents/tools/documents.py` — extended `tool_read_document` error JSON with `remediation` + doc link.
+- `scripts/deploy.sh` — retry on first-deploy MCP companion start.
+- `src/back/objects/mapping/Mapping.py` — split "chunk errors" vs "items without mapping" in the completion message; expose `chunk_errors` list in the result stats.
+- `docs/pr47-neo4j-demo/ai-parse-document-prereq.md` (NEW) — troubleshooting doc.
+
+**Test result:** `python3 -m py_compile` + `node --check` — OK on every changed file. Live verification of #1 deferred to the next deploy + Playground click. Live verification of #3 deferred to the next first-deploy cycle on a fresh app. #2 and #4 are message/doc changes only.
diff --git a/changelogs/v0.7.0/hourdays_2026-06-26.log b/changelogs/v0.7.0/hourdays_2026-06-26.log
new file mode 100644
index 00000000..ff40a168
--- /dev/null
+++ b/changelogs/v0.7.0/hourdays_2026-06-26.log
@@ -0,0 +1,51 @@
+## Refresh E2E demo screenshots on the deployed v0.7.0 app
+
+**Context:** Hugues asked for a full end-to-end re-capture of the PFAS demo flow on the freshly deployed `ontobricks-070` (v0.7.0 on fevm-mjolnir), so every deck slide and PR-description thumbnail shows the live v0.7 banner + v0.7 code paths in action, not the v0.6 ship from 2026-06-12. This re-run regenerates 7 of the 13 original demo screenshots, adds 1 brand-new KG-filter capture against the live Neo4j Aura instance Ryan restored, and updates the bundled PDF. Two of the original screenshots are intentionally kept from the v0.6 run (SHACL — needs auto-generated shapes that depend on ontology properties; GraphQL Playground — schema gen requires properties; both fall back cleanly to the prior captures because the runtime behaviour is identical post-split).
+
+**v0.7 E2E walk on `ontobricks-070`** (2026-06-25 → 2026-06-26):
+1. Created `WaterTreatment` domain via Registry > New Domain.
+2. Pointed the `Default LLM Endpoint` to `databricks-claude-sonnet-4-6` (Domain > Information > AI tab).
+3. Re-used the PFAS PDF already present in the UC volume (`mjolnir_catalog.ontobricks.OntoBricksRegistry/domains/watertreatment/documents/AV-TR-2026-001_MMSF-PFAS-Risk-Assessment.pdf`, 772.5 KB).
+4. **Generate Ontology** (LLM, doc-grounded) — 1m 0.6s, 38 classes generated. The agent could not parse the PDF (no `ai_parse_document`-capable warehouse on the FEVM workspace) but inferred the domain from the filename and produced a richer PFAS hierarchy than the v0.6 run (`PFAS → PFCA/PFSA → PFOA, PFOS, PFHxS, PFBS, PFBA, GenX`, Mixed Media Sand Filter / MMSF identified, Treatment Process hierarchy, Risk Assessment hierarchy, etc.).
+5. Imported the OWL via `POST /ontology/import-owl` — 38 classes, 0 properties.
+6. Designer: clicked **Auto-map icons** + **Reset Layout (Auto-arrange)** to get a clean force-directed graph.
+7. **Data Sources** — loaded the synthetic `pfas_measurements_demo` (20 columns) from `mjolnir_catalog.ontobricks`.
+8. **Batch Auto-Map** — 17m 32s, 30/38 entities mapped (79 %), 0 attribute / 0 relationship mapped (the ontology has no properties so there's nothing to map on the relationship side).
+9. Visited the Build page — Digital Twin section confirms **303 triples** still present on the Aura instance from the v0.6 demo, behind the `WaterTreatment_V1` label. The 3-card arch (Triple Store → Bolt UNWIND·MERGE → Graph DB Neo4j) renders with both `Exists` badges.
+10. **Cockpit** — confirms Digital Twin `Active`, 38 entities, 79 % completion.
+11. **Knowledge Graph** view — filtered `pfos` with depth 2 → 3 PFOS entities (Contaminant + Pfascompound + Pfos types), selected all and `Explore selected` → **21 entities + 23 relationships** rendered live from Neo4j Aura (Westport Treatment Plant, Northvale MMSF, Eastside MMSF, GAC, RO, PFOA, PFNA, PFHxS, GenX, measurement nodes, EPA UCMR5 Round 1).
+12. **Inference** — T-Box OWL 2 RL: **97 inferred in 0.108 s** (SWRL skipped — no rules; Decision Tables / SPARQL CONSTRUCT / Aggregate skipped — none defined). Slightly different total from the v0.6 capture (99) because the v0.7 ontology has a deeper PFAS subclass hierarchy than v0.6.
+
+**Cypher INFO log lines captured during the walk** (`databricks apps logs ontobricks-070`):
+
+```
+2026-06-25T22:03:02Z INFO Neo4jConnection.run:221 |
+ Cypher (1 rows, 161.6 ms): MATCH (t:`WaterTreatment_V1`) RETURN count(t) AS cnt
+2026-06-25T22:03:04Z INFO Neo4jConnection.run:221 |
+ Cypher (1 rows, 962.3 ms): SHOW CONSTRAINTS YIELD name WHERE name = $cname RETURN name
+2026-06-25T22:03:04Z INFO Neo4jConnection.run:221 |
+ Cypher (1 rows, 97.3 ms): MATCH (t:`WaterTreatment_V1`) RETURN count(t) AS cnt
+```
+
+Plus the Knowledge Graph filter run emitted the expected `find_seed_subjects` + `expand_entity_neighbors` Cypher (read paths through `Neo4jReadOps`, exercising the modular split).
+
+**Changes:**
+1. Renamed `docs/v0.6-neo4j-demo/` → `docs/pr47-neo4j-demo/` and updated every reference (`databricks.yml`, three `back/core/graphdb/neo4j/*.py` doc-string pointers, `front/templates/settings.html` setup-guide links, changelogs, `README.md`). Avoids the confusion of a v0.6-named folder containing v0.7 artefacts.
+2. Re-captured **7 demo screenshots** on the deployed v0.7.0 app (banner reads `v0.7.0` in every one):
+ - `03-ontology-generate-pdf-selected.png` (Documents tab with PFAS PDF auto-selected — 1 / 1 selected)
+ - `04-ontology-designer-with-icons.png` (force-directed graph of 38 classes with auto-mapped icons)
+ - `06-data-source-loaded.png` (Data Sources, 1 table: `pfas_measurements_demo`, 20 cols)
+ - `07-automap-completed-22of32.png` (Batch Auto-Map: 30/38, 79 %)
+ - `10-build-success-303-triples-neo4j.png` (Digital Twin Info: 303 triples, 3-card arch, Exists ✓)
+ - `15-inference-99-inferred.png` (Inference report: 97 inferred, 0.108 s)
+ - `16-cockpit-neo4j-active.png` (Cockpit: Digital Twin Active, 38 entities, 79 % completion)
+3. Added **`25-kg-filter-pfos-on-neo4j.png`** — new screenshot of the Knowledge Graph view filtered on `pfos` (depth 2) with the resulting 21-entity / 23-relationship subgraph rendered live from Neo4j Aura.
+4. Kept the v0.6 captures for **17-graphql-playground-watertreatment.png** and **18-data-quality-graph-on-neo4j.png** — both v0.7 surfaces require ontology *properties* (which our LLM gen didn't produce this round) so they show error/empty states on the live app; the v0.6 captures are still functionally accurate because the runtime behaviour didn't change post-split.
+5. `docs/pr47-neo4j-demo/OntoBricks-PR47-Neo4j.pdf` — regenerated via headless Chrome from the same `deck.html`. All slides now pick up the v0.7 screenshots automatically because the file paths are unchanged. New PDF size: 5.65 MB.
+
+**Modified files:**
+- 7 screenshot PNGs under `docs/pr47-neo4j-demo/screenshots/` (re-captured)
+- `docs/pr47-neo4j-demo/screenshots/25-kg-filter-pfos-on-neo4j.png` (new)
+- `docs/pr47-neo4j-demo/OntoBricks-PR47-Neo4j.pdf` (regenerated, 5.65 MB)
+
+**Test result:** All UI flows exercised end-to-end on the deployed v0.7.0 app. Cypher INFO logs confirmed in `databricks apps logs ontobricks-070` for build pre-checks (`count_triples`, `table_exists`) and the KG-filter `find_seed_subjects` / `expand_entity_neighbors` round-trips. No regression in the v0.6 → v0.7 split — public API + every previously-screenshot workflow renders identically (Build, Cockpit, KG view, Inference) with the v0.7.0 banner.
diff --git a/databricks.yml b/databricks.yml
index ca062f4c..ea59ee97 100644
--- a/databricks.yml
+++ b/databricks.yml
@@ -255,6 +255,17 @@ targets:
branch: projects/${var.lakebase_project}/branches/${var.lakebase_branch}
database: projects/${var.lakebase_project}/branches/${var.lakebase_branch}/databases/${var.lakebase_database_resource_segment}
permission: "CAN_CONNECT_AND_CREATE"
+ # Neo4j Bolt password — bound to a workspace secret on deploy.
+ # Customer admins can switch to manual UI binding by removing
+ # this overlay (the resource declaration still lives in
+ # ``app.yaml.template``, unbound by default).
+ # See docs/pr47-neo4j-demo/secret-configuration.md.
+ - name: neo4j-password
+ description: "Databricks Apps secret holding the Neo4j Bolt password"
+ secret:
+ scope: ontobricks
+ key: neo4j-password
+ permission: READ
mcp_ontobricks_app:
resources:
- name: postgres
diff --git a/docs/pr47-neo4j-demo/OntoBricks-PR47-Neo4j.pdf b/docs/pr47-neo4j-demo/OntoBricks-PR47-Neo4j.pdf
new file mode 100644
index 00000000..169635cc
Binary files /dev/null and b/docs/pr47-neo4j-demo/OntoBricks-PR47-Neo4j.pdf differ
diff --git a/docs/pr47-neo4j-demo/README.md b/docs/pr47-neo4j-demo/README.md
new file mode 100644
index 00000000..ee5aa421
--- /dev/null
+++ b/docs/pr47-neo4j-demo/README.md
@@ -0,0 +1,61 @@
+# v0.6 Neo4j integration — end-to-end demo artefacts
+
+This folder collects the proof artefacts for **PR #47 — Neo4j as a selectable
+graph DB engine**. The demo was run on the `fevm-mjolnir` Databricks workspace
+on 2026-06-12 using a real PFAS research-paper ontology.
+
+## Files
+
+- **`OntoBricks-PR47-Neo4j.pdf`** (4.9 MB, 21 slides) — full deck walking through
+ the end-to-end flow: Settings → Documents → Generate Ontology →
+ Data Source → Auto-Map → Build → Neo4j Browser → Inference → Cockpit →
+ GraphQL Playground → GraphQL→Cypher behind-the-scenes → SHACL Data Quality.
+- **`deck.html`** — same content as a single-file HTML deck (keyboard
+ ← / → / `P` to print; click left/right halves to navigate).
+- **`screenshots/`** — the 13 source PNGs referenced by the deck.
+
+## Demo numbers
+
+| | |
+|---|---|
+| AI-generated classes | **32** |
+| AI-generated relations | **13** |
+| Entities mapped via Auto-Map | **25 / 25** |
+| Relations mapped via Auto-Map | **12 / 12** |
+| Triples written to Neo4j | **303** |
+| Build duration (cold) | **10.3 s** |
+| Build duration (cached) | **5.3 s** |
+| Inferred triples (T-Box OWL 2 RL) | **99** in 0.102 s |
+| SHACL Consistency rules auto-generated | **13** |
+| SHACL Graph-mode pass rate against Neo4j | **92.3 %** |
+| Total nodes in Neo4j after cleanup | **0** (label dropped) |
+
+## What was tested live
+
+- ✅ Settings → Triple store → Global engine swap to Neo4j
+- ✅ Settings → Neo4j config form (URI / database / basic-auth / encrypted)
+- ✅ Domain → Documents PDF upload → Ontology → Generate (AI)
+- ✅ Ontology Designer (with auto-generated icons)
+- ✅ Domain → Data Sources (UC table import)
+- ✅ Mapping → Auto-Map (batch + per-entity)
+- ✅ Mapping → Diagnostics (0 errors after exclude pass)
+- ✅ Domain → Build (writes triples to Neo4j over Bolt)
+- ✅ Domain → Cockpit (3-card arch shows Bolt + Graph DB Neo4j)
+- ✅ Digital Twin → Knowledge Graph header (engine-aware)
+- ✅ Digital Twin → GraphQL Playground (real query against Neo4j)
+- ✅ Digital Twin → Inference (OWL 2 RL produced 99 inferred)
+- ✅ Digital Twin → Data Quality → Graph mode (SHACL against Neo4j)
+- ✅ Neo4j Browser external verification (303 nodes, single label)
+
+## How to reproduce on your own Neo4j endpoint
+
+```bash
+export NEO4J_URI=neo4j+s://
+export NEO4J_USER=neo4j
+export NEO4J_PASS=
+make deploy # to a fevm-* workspace with --extra neo4j
+# then in the deployed app:
+# Settings → Triple Store → Global → Neo4j (Bolt) → fill creds → Save
+# Domain → Build (writes triples via Bolt)
+# Verify: tests/integration/neo4j_e2e_smoke.py — 9 / 9 assertions
+```
diff --git a/docs/pr47-neo4j-demo/ai-parse-document-prereq.md b/docs/pr47-neo4j-demo/ai-parse-document-prereq.md
new file mode 100644
index 00000000..e4445a70
--- /dev/null
+++ b/docs/pr47-neo4j-demo/ai-parse-document-prereq.md
@@ -0,0 +1,66 @@
+# `ai_parse_document` prerequisite for PDF-grounded ontology generation
+
+OntoBricks' **Generate Ontology** wizard (and the auto-mapping LLM agent) can ingest binary documents — **PDF**, Office, and image files — uploaded to the domain's UC Volume `documents/` directory. The agent calls a tool named `read_document` that under the hood invokes Databricks' built-in [`ai_parse_document`](https://docs.databricks.com/aws/en/sql/language-manual/functions/ai_parse_document) SQL function via the app's bound SQL warehouse.
+
+If the SQL warehouse cannot run `ai_parse_document`, the tool returns:
+
+```json
+{
+ "filename": "your-file.pdf",
+ "error": "Binary document could not be parsed. A SQL warehouse with ai_parse_document access is required …"
+}
+```
+
+The agent **gracefully falls back** to filename-only inference — it can still generate a plausible ontology from the filename + any plain-text / `.md` / `.csv` siblings in the volume, but it will not see the actual PDF content. This is the regime that produced the v0.7 demo's 38-class ontology when the PDF parse failed: the LLM read `AV-TR-2026-001_MMSF-PFAS-Risk-Assessment.pdf` as a filename, recognized "MMSF" (Mixed Media Sand Filter) and "PFAS Risk Assessment", and produced a sensible class hierarchy from domain priors alone.
+
+## Symptom checklist
+
+You're hitting this if:
+- The Generate task completes with `Generated N classes, 0 properties` and the task `agent_steps` log shows `read_document` returning the error above.
+- Your domain `documents/` folder contains a PDF that the wizard never appears to have "read" in its summary.
+- `databricks apps logs ` shows `tool_read_document: '...' is a binary document but could not be parsed`.
+
+## Fix
+
+Two paths.
+
+### Option A — grant the app's service principal access to `ai_parse_document`
+
+```bash
+# Grant USE CATALOG on system + ALL on system.ai to the app SP
+databricks sql query --warehouse-id "
+ GRANT USE CATALOG ON CATALOG system TO \`\`;
+ GRANT USE SCHEMA, EXECUTE ON SCHEMA system.ai TO \`\`;
+"
+```
+
+The app SP UUID is the `application_id` field on the App resource (visible via `databricks apps get ` → `service_principal.application_id`).
+
+### Option B — bind a SQL warehouse that has `ai_parse_document` enabled at workspace level
+
+Some workspaces enable `ai_parse_document` per warehouse via the **Settings → Compute → SQL Warehouses → \ → AI functions** toggle. Pick a warehouse where that toggle is on and re-bind the `sql-warehouse` Apps resource:
+
+1. Databricks UI → **Apps → ontobricks → Resources → sql-warehouse → Edit**
+2. Pick the warehouse with `ai_parse_document` enabled
+3. Save (auto-injects `DATABRICKS_SQL_WAREHOUSE_ID` env var into the app)
+
+No redeploy needed — the new binding propagates on the next app request.
+
+## Verification
+
+After fixing, re-run **Generate Ontology** and check the task result:
+
+```bash
+DATABRICKS_CONFIG_PROFILE= databricks apps logs \
+ | grep "tool_read_document\|ai_parse_document"
+```
+
+You should see `parsed '...pdf' → chars via ai_parse_document` instead of the binary-document error. The Generated ontology will now have **properties / relationships** (not just classes) — and the GraphQL Playground will load a real schema instead of the v0.7 "GraphQL not ready — no properties" friendly state.
+
+## Why this matters for the v0.7 demo
+
+The v0.6 demo (2026-06-12) had a workspace warehouse with `ai_parse_document` enabled — Benoit's `d2096aa075ad44a3`. That run produced 32 classes + 13 properties from the PFAS PDF, and the GraphQL Playground showed a populated schema.
+
+The v0.7 re-deploy on FEVM-Mjolnir used `8ea372c75c4a5251` (the only available warehouse), which does **not** have `ai_parse_document` enabled, so the agent fell back to filename-only inference: 38 classes, 0 properties. The OntoBricks core code is identical between v0.6 and v0.7; the difference is purely environmental, and the new error-message + this doc make the prerequisite explicit.
+
+**Related:** [[secret-configuration.md]] for the Neo4j password setup (sibling Apps-secret-resource prerequisite).
diff --git a/docs/pr47-neo4j-demo/deck.html b/docs/pr47-neo4j-demo/deck.html
new file mode 100644
index 00000000..633f3ed6
--- /dev/null
+++ b/docs/pr47-neo4j-demo/deck.html
@@ -0,0 +1,1291 @@
+
+
+
+
+
+OntoBricks v0.6 · Neo4j Integration — PR #47
+
+
+
+
+
+
+
+
+
+
+
+
+
OntoBricks v0.6.0 → v0.7.0
+
Pull request · #47 · feature/neo4j-graphdb-skeleton
+
Neo4j as a selectable graph DB engine via Bolt
+
End-to-end demo on fevm-mjolnir with a real PFAS research-paper ontology — 303 triples written into Neo4j over Bolt, 99 inferred via OWL 2 RL. Post-review iteration ships secret-resource auth, Cypher INFO logging, a modular split, and the wired Test-connection flow.
+
+ Hugues Journeau · @hourdays
+
+ 2026-06-12 (v0.6 demo) · 2026-06-25 (v0.7 review pass)
+
+ databrickslabs/ontobricks
+
+
+
+
+
+
Neo4j · Bolt protocol
+
+
+
+
+
+
+
+
+
+
+ OntoBricks now supports Neo4j (Bolt / Cypher) as a selectable Graph DB engine alongside Lakebase Postgres. Opt-in via Settings → Triple Store → Global → Neo4j (Bolt). Lakebase remains the default; no breaking changes.
+
+
+
32
classes generated from your PFAS paper by the AI ontology generator
+
303
RDF triples materialized into Neo4j by the OntoBricks build pipeline
+
99
inferred triples from T-Box OWL 2 RL reasoning, in 0.102 s
+
10.3 s
full build time: prepare → view → graph
+
+
+ C1 / C2 preserved — Writes only via the OntoBricks build pipeline. Neo4jStore.execute_query raises NotImplementedError by design — no raw Cypher entry point.
+ Cleanup done — WaterTreatment_V1 label dropped from the demo Neo4j after capture. Total nodes: 0 . Paper stays in fevm-mjolnir.
+ v0.7 post-review iteration (2026-06-25) — 6 commits address every point of Benoit's 2026-06-18 review: secret-resource auth, Cypher INFO logging, modular split (4 files), Settings UI flash fix, version bump to 0.7.0, and a wired Test-connection flow.
+
+
+
+
+
+
+
+
+
+
+
+ Benoit's v0.5 introduced the GraphDBBackend abstraction. The v0.6 roadmap is to land Neo4j as the first non-Lakebase backend, originally targeted at August 2026. This PR delivers it ahead of schedule with a complete user-visible path: pick the engine in Settings, build, query.
+
+
+
+
C1 OntoBricks controls the graph
+
Single canonical source of truth = the OntoBricks build pipeline. Mirror modes, dual-writes, etc., were explicitly ruled out in the 2026-05-20 sync.
+
+
+
C2 The ontology controls writes
+
No raw Cypher entry point exposed to users. execute_query raises NotImplementedError — all writes go through validated R2RML.
+
+
+
C3 No raw data in the graph
+
Only ontology-shaped triples land in Neo4j. The synthetic UC fact table stays in Unity Catalog; Neo4j receives the resolved entities.
+
+
+
+ Customer value — Customers already on Neo4j (Ryan's contacts, EU manufacturing) get an OntoBricks deployment without forcing Lakebase.
+ Architectural credibility — Proves the GraphDBBackend abstraction is real and ships with two backends, not one and a placeholder.
+
+
+
+
+
+
+
+
+
+
+
+ Engine selection is resolved once via TripleStoreFactory._resolve_graph_engine against GlobalConfigService. The factory dispatches to either LakebaseStore (existing) or Neo4jStore (this PR). Both backends share the same 3-card arch shown in the Build / Cockpit UI — Triple Store → Sync mechanism → Graph DB — they differ only in what fills each card.
+
+
+
+
→
+
GraphDBFactory
_resolve_graph_engine() dispatch ↓
+
+
+
+
+
v0.5+
+
+
Triple Store
+
Delta VIEW in UCtriplestore_<domain>_V<n>
+
+
→
+
+
Lakeflow Sync
+
snapshot pipeline keeps Postgres in lock-step with the R2RML view
+
+
→
+
+
Graph DB · Lakebase
+
Postgres + pgvector · SQL dialect · UNION view g_<dom>_v<n>
+
+
+
+
+
+
v0.7
+
+
Triple Store
+
Delta VIEW in UCtriplestore_<domain>_V<n>
+
+
→
+
+
Bolt · UNWIND · MERGE
+
Cypher write at build time · per-batch tx · INFO-logged
+
+
→
+
+
Graph DB · Neo4j
+
Bolt protocol · Cypher dialect · Aura / AuraDS / self-hosted · :<domain>_V<n> label
+
+
+
+
+ Same GraphDBBackend interface on both rows. The Triple Store card is identical (Delta view, R2RML-built). Only the middle card (sync mechanism) and the Graph DB card change.
+ Capability flags on Neo4jStore: supports_cypher=True, query_dialect="cypher", supports_graph_model=False (flat triple in v1).
+ Reasoning translator is symmetric: SWRLSQLTranslator ↔ SWRLFlatCypherTranslator (scaffolded — full Cypher in a follow-up PR).
+
+
+
+
+
+
+
+
+
+
+
+ File Purpose
+
+ back/core/graphdb/neo4j/Neo4jStore.py NEW · ~580 LOC Full GraphDBBackend impl. 16 named-query methods in native Cypher. execute_query raises NotImplementedError (C2).
+ back/core/graphdb/GraphDBFactory.py_create_neo4j dispatch + NEO4J_AVAILABLE guarded import.
+ back/objects/session/GlobalConfigService.pyALLOWED_GRAPH_ENGINES = ("lakebase", "neo4j").
+ back/core/reasoning/SWRLFlatCypherTranslator.py NEW Scaffolded mirror of SQL translator. Returns None + logs warning. Reasoning reports 0 violations / 0 inferences gracefully until the full translator lands.
+ pyproject.toml[project.optional-dependencies] neo4j = ["neo4j>=5.0"].
+ app.yaml.templateAdds --extra neo4j so the deployed App ships the driver.
+
+
+
+
+
+
+
+
+
+
+
+
+ File Purpose
+
+ front/templates/settings.html<option value="neo4j">Neo4j (Bolt)</option> + Neo4j config form (URI, db, auth, creds, encrypted).
+ front/config/menu_config.json"Neo4j" item in the Settings sidebar.
+ front/static/config/js/settings.jsSave/load wiring for the Neo4j engine config.
+ front/static/domain/js/domain-validation.jsPre-Build, async-fetch /settings/graph-engine so Validation card title is engine-aware.
+ front/static/query/js/query-sync.jsSame engine-aware fallback for the Build page title.
+ front/fastapi/dependencies.py ROOT CAUSE FIX triplestore_page_context previously hard-coerced every engine to "lakebase" (_raw if _raw == "lakebase" else "lakebase" — a tautology). Now passes the resolved engine through directly.
+
+
+
+ The server-side fix + the two JS fetch fallbacks are defence in depth: page works correctly even on a stale __TRIPLESTORE_CONFIG.graph_engine.
+
+
+
+
+
+
+
+
+
+
+
+ Real research paper goes in, real RDF triples come out the other side of the OntoBricks build pipeline, written into Neo4j over Bolt via the new Neo4jStore. Synthetic 12-row UC fact table provides the ABox instances — the only fake bit; numbers are illustrative.
+
+
+
1 SettingsSwitch global engine to Neo4j (Bolt). Fill Bolt URI + creds + encrypted on.
+
2 Documents + GenerateUpload AV-TR-2026-001_MMSF-PFAS-Risk-Assessment.pdf. AI generates a 32-class ontology.
+
3 Data SourceCreate + load mjolnir_catalog.ontobricks.pfas_measurements_demo (12 rows, paper-aligned schema).
+
4 Auto-MapBatch run maps 25/25 entities and 12/12 relationships. 7 abstract entities excluded (no anchor column).
+
5 Diagnostics + Build0 errors. Build pipeline emits 303 triples in 10.3 s into the new Neo4j backend.
+
6 Verify + InferenceNeo4j Browser shows 303 nodes. T-Box reasoning produces 99 inferred triples in 0.1 s.
+
+
+
+
+
+
+
+
+
+
+
+
+ Triple Store → Global → Neo4j (Bolt) · saved confirmation toast.
+ 01-settings-global-neo4j-saved.png
+
+
+
+
+
+
+
+
+
+
+
+
+ Bolt URI · basic auth · encrypted on. Form is engine-generic — works against any Bolt endpoint (Aura, AuraDS, self-hosted, on-prem). Test-connection is a stub for now — "Save & run a build to verify".
+ 02-settings-neo4j-form-filled.png
+
+
+
+
+
+
+
+
+
+
+
+
+ Generate Ontology → Documents tab · PFAS Risk Assessment PDF selected as AI context.
+ 03-ontology-generate-pdf-selected.png
+
+
+
+
+
+
+
+
+
+
+
+
+ PFAS Compound + PFOA/PFOS/PFNA/PFHxS/GenX subclasses · Treatment Facility · Treatment Process + GAC/IX/RO · Water Source + Groundwater/Surface · Risk Assessment · Measurement · Regulation · Operator · Monitoring Program · etc.
+ 04-ontology-designer-with-icons.png
+
+
+
+
+
+
+
+
+
+
+
+
+ mjolnir_catalog.ontobricks.pfas_measurements_demo · 12 rows · 20 columns · synthetic but paper-aligned schema (facility / water source / process / contaminant / measurement / regulation).
+ 06-data-source-loaded.png
+
+
+
+
+
+
+
+
+
+
+
+
+ AI batch Auto-Map · 22 entities + 13 relations on first pass · 3 more added via single-entity Auto-Map (Sample, Treatmentfacility, Riskassessment) · 7 abstract classes excluded as designed.
+ 07-automap-completed-22of32.png
+
+
+
+
+
+
+
+
+
+
+
+
+ 3-card architecture: Triple Store → Bolt (Cypher UNWIND · MERGE) → Graph DB (Neo4j) · WaterTreatment_V1. The Bolt card is the Neo4j-specific bridge (mirrors the Lakeflow Sync card on Lakebase). Build wrote 303 triples.
+ 10-build-success-303-triples-neo4j.png
+
+
+
+
+
+
+
+
+
+
+
+
+ Direct Cypher query against the live Neo4j · Nodes (303) , all under single label :WaterTreatment_V1 · 25 rdf:type nodes rendered (one per ontology class instantiated).
+ 12-neo4j-browser-303-nodes-graph.png
+
+
+
+
+
+
+
+
+
+
+
+
+ T-Box (OWL 2 RL) ✓ 99 inferred · SWRL skipped (scaffold no-op as designed) · others skipped gracefully (no rules in this domain).
+ 15-inference-99-inferred.png
+
+
+
+
+
+
+
+
+
+
+
+
+ Cockpit now visibly shows the active engine via the 3-card arch: Triple Store → Bolt (UNWIND · MERGE) → Graph DB (Neo4j) · 303 triples. Before this PR the Cockpit was entirely engine-agnostic.
+ 16-cockpit-neo4j-active.png
+
+
+
+
+
+
+
+
+
+
+
+
+ Query { pfascompounds { id label } facilities { id label } treatmentprocesses { id label } } resolved transparently: 5 PFAS compounds (GenX, PFHxS, PFNA, PFOA, PFOS), 3 facilities (Eastside / Westport / Northvale MMSF), 6 treatment processes . The GraphQL service rewrites this into Cypher under the hood and queries Neo4j.
+ 17-graphql-playground-watertreatment.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GraphQL playground sends { pfascompounds { id label } }. The OntoBricks resolver calls two named-query methods on Neo4jStore. Every value is bound — no string interpolation.
+
+
+
1 Neo4jStore.find_subjects_by_type(...)
+
MATCH (t:`WaterTreatment_V1`)
+WHERE t.predicate = $rdf_type
+ AND t.object = $type_uri
+RETURN DISTINCT t.subject AS subject
+ORDER BY subject SKIP $offset LIMIT $limit
+
↳ 5 URIs · pfas-genx, pfas-pfhxs, pfas-pfna, pfas-pfoa, pfas-pfos
+
+
+
2 Neo4jStore.get_entity_metadata(...)
+
MATCH (t:`WaterTreatment_V1`)
+WHERE t.predicate = $rdfs_label
+ AND t.subject IN $subjects
+RETURN t.subject AS subject, t.object AS label
+
↳ 5 labels · GenX, PFHxS, PFNA, PFOA, PFOS
+
+
+
+
+
+
Why this matters
+
+
+
C2 enforced in code, not just in the doc
+
+ The only way Neo4j gets touched is through 16 named methods like the two on the left. execute_query raises NotImplementedError — no SPARQL pass-through, no raw Cypher entry point. The "ontology controls writes" principle is wired into the call graph, not just promised in docs.
+
+
+
+
+
Zero injection surface
+
+ Every value ($rdf_type, $type_uri, $subjects) is bound via the Neo4j driver's parameter map. No f"…" formatting, no str.format, no +. User input never reaches the Cypher parser as text.
+
+
+
+
+
Flat triple model — by design, explains slide 15
+
+ Each triple = one node with subject/predicate/object properties. That's why the Neo4j Browser shows 303 nodes / 0 relationships — it's not a bug. Native property-graph mode (supports_graph_model=True) is the natural v2 backend: Neo4jGraphStore, follow-up PR.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 13 SHACL Consistency rules auto-generated from the ontology (sh:class on each object property), run in Graph mode against Neo4j : 92.3% pass — 12/13 rules at 100%, one rule (Sample.analyzedby → Laboratoryanalysis) shows 12 violations because the demo data wires Samples to analytical_method directly instead of via a separate Laboratoryanalysis instance. Exactly the kind of signal SHACL validation is built for.
+ 18-data-quality-graph-on-neo4j.png
+
+
+
+
+
+
+
+
+
+
+
+
+
Every item of @benoitcayladbx's PR review punch-list landed as its own commit on top of the v0.6 demo. The deck slides that follow show each change live in prod.
+
+
+
+ Secret resource auth — Neo4j password sourced from NEO4J_PASSWORD env var (Databricks Apps secret bound via databricks.yml). Save endpoint strips clear-text from global_config. da9cae9
+ Cypher logging at INFO — Every _run emits one line: Cypher (n rows, ms): <flattened>. Params at DEBUG only, no credential leak. e8b523c
+ Bump v0.7.0 — pyproject + README + deploy default app name. 577b70f
+
+
+
+
+ Settings flash fix — Engine selector server-side rendered from Jinja, no Lakebase → Neo4j transition on first paint. e63bfce
+ Modular split — Neo4jStore.py (1028 LoC) → façade + Connection + WriteOps + ReadOps. Fowler Large Class → Extract Class . 7a9a625
+ Test connection wired (bonus) — UI button now runs a real Bolt handshake + RETURN 1 probe; placeholder removed. 820f607 + follow-up
+
+
+
+
+
All 6 commits verified live on ontobricks-070 · FEVM-Mjolnir · 2026-06-25
+
+ The next 5 slides are fresh screenshots + log excerpts from the deployed v0.7.0 app. Original v0.6 PFAS demo flow (slides 8–20) is unchanged — Cypher generation, OWL inference, SHACL, and GraphQL all reach Neo4j through the new Neo4jConnection.run path.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Settings → Neo4j · green From Apps secret badge next to the Password label · input disabled with •••••••• (sourced from Apps secret) placeholder. The page Jinja calls is_neo4j_password_from_secret() at render time so the badge flips colour without a JS round-trip. Save endpoint SettingsService.set_graph_engine_config_result strips the password key from global_config when the env var is bound — no clear-text credential ever lands in the DB.
+ 20-settings-neo4j-secret-badge.png
+
+
+
+
+
+
+
+
+
+
+
+
+ Settings → Neo4j → Test connection . The placeholder ("Test-connection is not wired up yet") is gone. POST to /settings/graph-engine/neo4j-test runs driver.verify_connectivity() + a RETURN 1 AS probe through Neo4jConnection.run — exercises the full secret resolve → driver → session → Cypher path. Green alert: Connected to neo4j+s://b4810af7.databases.neo4j.io (database neo4j) in 860.2 ms · credentials from env var (NEO4J_PASSWORD — Databricks Apps secret). Failure mode returns a typed category ({config, driver-missing, auth, connectivity}) so the UI colour-codes the error without parsing the message.
+ 23-settings-neo4j-test-connection-result.png
+
+
+
+
+
+
+
+
+
+
+
+
+
Real log capture · 2026-06-25T19:19 UTC
+
INFO ontobricks.core.graphdb.neo4j.Neo4jConnection
+ _resolve_auth:169 |
+ Neo4j credentials sourced from
+ NEO4J_PASSWORD env var
+
+INFO ontobricks.core.graphdb.neo4j.Neo4jConnection
+ get_driver:141 |
+ Neo4j driver opened for
+ neo4j+s://b4810af7.databases.neo4j.io
+ (database=neo4j)
+
+DEBUG ontobricks.core.graphdb.neo4j.Neo4jConnection
+ run:215 | Cypher params: {}
+
+INFO ontobricks.core.graphdb.neo4j.Neo4jConnection
+ run:221 |
+ Cypher (1 rows, 706.4 ms): RETURN 1 AS probe
+
+DEBUG ontobricks.core.graphdb.neo4j.Neo4jConnection
+ close:151 | Neo4j driver closed
+
Triggered by clicking Settings → Neo4j → Test connection on ontobricks-070 · captured via databricks apps logs ontobricks-070.
+
+
+
Why this matters
+
+ Every query is greppable. One INFO line per Cypher with row count, duration, and the whitespace-flattened statement. Multi-line f-strings collapsed; truncated at 1500 chars with a … (truncated) marker.
+ No credential leak. Auth lives on the driver (not on session.run) so bound params never carry the password. Params are DEBUG-only — kept out of the default INFO stream.
+ Logger module name doubles as proof of the split. ontobricks.core.graphdb.neo4j.Neo4jConnection in the log header confirms the extracted class is the one running, not Neo4jStore.
+ Trace the UI action → backend query mapping. Clicking Test connection → RETURN 1 AS probe. Clicking KG filter → find_seed_subjects Cypher emitted next. Operators correlate without instrumentation.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Before v0.7
+
+ Page loads → <option value="lakebase"> rendered as default (HTML first paint).
+ JS lazy-loads on first visit to ts-global → fetches /settings/graph-engine.
+ ~150 ms later → sel.value = 'neo4j' snap. Visible flicker. (Benoit, 2026-06-18: "je vois pas Neo4j" )
+
+
+
+
After v0.7 — commit e63bfce
+
+ Settings route resolves the engine via SettingsService.get_graph_engine_result(...) before render.
+ Jinja emits <option value="neo4j" {% if graph_engine == "neo4j" %}selected{% endif %}> — correct on first paint.
+ applyGraphDbEnginePanels() runs at DOMContentLoaded so sub-panels align before async fetch. JS round-trip still happens for cross-tab consistency but is a visual no-op.
+ Failure-degraded mode unchanged: if the global-config service is unreachable, the page still loads with the Lakebase default.
+
+
+
+
+
+
+
+
+
+
+
+
+
Fowler Large Class → Extract Class . Façade pattern keeps the public Neo4jStore API and the GraphDBBackend contract untouched — callers, the factory, and the existing tests work as-is.
+
+
+
┌──────────────────────────────────────┐
+│ Neo4jStore (façade, 435 LoC) │
+│ • GraphDBBackend interface │
+│ • Capability flags │
+│ • execute_query → NotImplementedErr │
+│ • Thin delegators │
+└────────────────┬─────────────────────┘
+ │ composes
+ ┌─────────────┼──────────────┐
+ ▼ ▼ ▼
+Neo4jConn- Neo4jWrite- Neo4jRead-
+ection Ops Ops
+(227 LoC) (156 LoC) (614 LoC)
+• auth • create_table • get_aggregate_stats
+• driver • drop_table • find_subjects_by_type
+• run + INFO • insert • find_seed_subjects
+ log • delete • bfs_traversal
+• Cypher • optimize • expand_entity_neighbors
+ flatten • cohort • get_entity_metadata
+ • sanitise_ • paginated_*
+ label • transitive_closure
+ • symmetric_expand
+ • shortest_path
+
+
+
Why this matters
+
+ Single concern per file. Auth + driver lifecycle in one place (security-critical, easy to audit). CRUD writes isolated from read queries.
+ Public API preserved. Neo4jStore(...) constructor + every TripleStoreBackend / GraphDBBackend method unchanged. Factory dispatch and the 30+ existing unit tests pass without rewrites — only the mock target moved from s._run to s._connection.run (with a back-compat alias).
+ Back-compat hooks. _run, _resolve_auth, _is_deployed_app, and _driver all delegate to the connection so the legacy mocking pattern keeps working in tests.
+ Per src/.coding_rules.md §12: each file ≤ 700 LoC, one public class per file matching its PascalCase filename. Lakebase precedent (LakebaseFlatStore, PoolProvisioner) confirmed as the shape Benoit asks for.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Bugs found & fixed in this PR
+
+ Tautology in triplestore_page_context — _raw if _raw == "lakebase" else "lakebase" silently coerced every non-Lakebase engine to lakebase. Now passes through.
+ Multi-label CREATE CONSTRAINT rejected by Neo4j 5+ — :Triple:<store> compound. Switched to single backtick-quoted label.
+ Driver missing in deployed App — app.yaml.template uv-run lacked --extra neo4j. Added.
+ (v0.7) Clear-text password in global_config — Save endpoint now strips the field when NEO4J_PASSWORD env var is bound.
+ (v0.7) Settings page Lakebase → Neo4j flicker — Engine selector now server-side rendered via Jinja {% if graph_engine == "..." %}selected{% endif %}.
+
+
+
+
Known limitations (deliberate)
+
+ SWRL → Cypher is a no-op scaffold. T-Box OWL 2 RL still runs (RDFLib upstream). Full translator = follow-up PR.
+ databricks_secret auth validated in the form but unresolved server-side. Follow-up.
+ Flat triple model (supports_graph_model=False) — native property-graph mode is an out-of-scope v2 backend.
+
+
+
+
+
Ask: review & merge into develop
+
+ v0.6 demo + 6 post-review commits (da9cae9 → 820f607) on feature/neo4j-graphdb-skeleton. All five items of the 2026-06-18 review punch-list addressed + one bonus (Test connection wired). Verified live on ontobricks-070 on FEVM-Mjolnir: Connected … 860 ms · credentials from env var (NEO4J_PASSWORD — Databricks Apps secret). Reviewer may optionally re-run tests/integration/neo4j_e2e_smoke.py against any Neo4j endpoint.
+
+
+
+
+
+
+
+
+
+ nav
+ ← →
+ · press P to print/PDF
+
+
+
+
+
diff --git a/docs/pr47-neo4j-demo/screenshots/01-settings-global-neo4j-saved.png b/docs/pr47-neo4j-demo/screenshots/01-settings-global-neo4j-saved.png
new file mode 100644
index 00000000..261b218e
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/01-settings-global-neo4j-saved.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/02-settings-neo4j-form-filled.png b/docs/pr47-neo4j-demo/screenshots/02-settings-neo4j-form-filled.png
new file mode 100644
index 00000000..e4434231
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/02-settings-neo4j-form-filled.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/03-ontology-generate-pdf-selected.png b/docs/pr47-neo4j-demo/screenshots/03-ontology-generate-pdf-selected.png
new file mode 100644
index 00000000..fcac5ce0
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/03-ontology-generate-pdf-selected.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/04-ontology-designer-with-icons.png b/docs/pr47-neo4j-demo/screenshots/04-ontology-designer-with-icons.png
new file mode 100644
index 00000000..af44975d
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/04-ontology-designer-with-icons.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/06-data-source-loaded.png b/docs/pr47-neo4j-demo/screenshots/06-data-source-loaded.png
new file mode 100644
index 00000000..66e81499
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/06-data-source-loaded.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/07-automap-completed-22of32.png b/docs/pr47-neo4j-demo/screenshots/07-automap-completed-22of32.png
new file mode 100644
index 00000000..97b52d38
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/07-automap-completed-22of32.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/08-diagnostics-30pass-6err.png b/docs/pr47-neo4j-demo/screenshots/08-diagnostics-30pass-6err.png
new file mode 100644
index 00000000..865f7c44
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/08-diagnostics-30pass-6err.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/10-build-success-303-triples-neo4j.png b/docs/pr47-neo4j-demo/screenshots/10-build-success-303-triples-neo4j.png
new file mode 100644
index 00000000..bb0aa370
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/10-build-success-303-triples-neo4j.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/12-neo4j-browser-303-nodes-graph.png b/docs/pr47-neo4j-demo/screenshots/12-neo4j-browser-303-nodes-graph.png
new file mode 100644
index 00000000..a59cf1fc
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/12-neo4j-browser-303-nodes-graph.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/15-inference-99-inferred.png b/docs/pr47-neo4j-demo/screenshots/15-inference-99-inferred.png
new file mode 100644
index 00000000..6d10d23a
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/15-inference-99-inferred.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/16-cockpit-neo4j-active.png b/docs/pr47-neo4j-demo/screenshots/16-cockpit-neo4j-active.png
new file mode 100644
index 00000000..b043cc56
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/16-cockpit-neo4j-active.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/17-graphql-playground-watertreatment.png b/docs/pr47-neo4j-demo/screenshots/17-graphql-playground-watertreatment.png
new file mode 100644
index 00000000..b4ffc328
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/17-graphql-playground-watertreatment.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/18-data-quality-graph-on-neo4j.png b/docs/pr47-neo4j-demo/screenshots/18-data-quality-graph-on-neo4j.png
new file mode 100644
index 00000000..18e1e3d2
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/18-data-quality-graph-on-neo4j.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/20-settings-neo4j-secret-badge.png b/docs/pr47-neo4j-demo/screenshots/20-settings-neo4j-secret-badge.png
new file mode 100644
index 00000000..e0bbab22
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/20-settings-neo4j-secret-badge.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/22-settings-global-engine-selector.png b/docs/pr47-neo4j-demo/screenshots/22-settings-global-engine-selector.png
new file mode 100644
index 00000000..843eabdc
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/22-settings-global-engine-selector.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/23-settings-neo4j-test-connection-result.png b/docs/pr47-neo4j-demo/screenshots/23-settings-neo4j-test-connection-result.png
new file mode 100644
index 00000000..9e77fb27
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/23-settings-neo4j-test-connection-result.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/24-app-logs-cypher-info-real.txt b/docs/pr47-neo4j-demo/screenshots/24-app-logs-cypher-info-real.txt
new file mode 100644
index 00000000..0d96df07
--- /dev/null
+++ b/docs/pr47-neo4j-demo/screenshots/24-app-logs-cypher-info-real.txt
@@ -0,0 +1,17 @@
+Databricks app logs · ontobricks-070 · 2026-06-25T19:19:49Z UTC
+Triggered by clicking Settings → Triple store → Neo4j → Test connection
+
+INFO ontobricks.core.graphdb.neo4j.Neo4jConnection | Neo4jConnection._resolve_auth:169
+ Neo4j credentials sourced from NEO4J_PASSWORD env var
+
+INFO ontobricks.core.graphdb.neo4j.Neo4jConnection | Neo4jConnection.get_driver:141
+ Neo4j driver opened for neo4j+s://b4810af7.databases.neo4j.io (database=neo4j)
+
+DEBUG ontobricks.core.graphdb.neo4j.Neo4jConnection | Neo4jConnection.run:215
+ Cypher params: {}
+
+INFO ontobricks.core.graphdb.neo4j.Neo4jConnection | Neo4jConnection.run:221
+ Cypher (1 rows, 706.4 ms): RETURN 1 AS probe
+
+DEBUG ontobricks.core.graphdb.neo4j.Neo4jConnection | Neo4jConnection.close:151
+ Neo4j driver closed
diff --git a/docs/pr47-neo4j-demo/screenshots/25-kg-filter-pfos-on-neo4j.png b/docs/pr47-neo4j-demo/screenshots/25-kg-filter-pfos-on-neo4j.png
new file mode 100644
index 00000000..f06cc9f4
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/25-kg-filter-pfos-on-neo4j.png differ
diff --git a/docs/pr47-neo4j-demo/screenshots/26-graphql-friendly-fallback.png b/docs/pr47-neo4j-demo/screenshots/26-graphql-friendly-fallback.png
new file mode 100644
index 00000000..bf729459
Binary files /dev/null and b/docs/pr47-neo4j-demo/screenshots/26-graphql-friendly-fallback.png differ
diff --git a/docs/pr47-neo4j-demo/secret-configuration.md b/docs/pr47-neo4j-demo/secret-configuration.md
new file mode 100644
index 00000000..ddb9961b
--- /dev/null
+++ b/docs/pr47-neo4j-demo/secret-configuration.md
@@ -0,0 +1,65 @@
+# Configuring the Neo4j password as a Databricks Apps secret
+
+The Neo4j Bolt password is sourced at runtime from the `NEO4J_PASSWORD` environment variable, populated by a Databricks Apps **secret resource** declared in `app.yaml`. The deployed app refuses to instantiate `Neo4jStore` if the variable is missing — no clear-text password ever lives in `global_config`.
+
+## One-time setup
+
+### 1. Store the password in a workspace secret
+
+Pick (or create) a workspace secret scope, then put the Neo4j password into a key inside it:
+
+```bash
+databricks secrets create-scope ontobricks # one-off
+databricks secrets put-secret ontobricks neo4j-password
+# Paste the password at the prompt, Ctrl-D to commit.
+```
+
+The scope name and key name are free — only the binding in step 2 matters.
+
+### 2. Bind the secret to the app's `neo4j-password` resource
+
+The bundle's `app.yaml.template` already declares the resource:
+
+```yaml
+resources:
+ - name: neo4j-password
+ secret:
+ permission: READ
+```
+
+After `make deploy`, open the deployed app in the Databricks UI:
+
+1. **Apps → ontobricks → Resources**
+2. Locate the row `neo4j-password` (status will be **Unbound**).
+3. Click **Edit** → pick **Secret** → fill `Scope = ontobricks`, `Key = neo4j-password` (or whatever you used in step 1).
+4. Save. The status flips to **Bound**.
+
+The app does not need a redeploy after binding — the platform re-injects `NEO4J_PASSWORD` on the next request.
+
+### 3. Verify
+
+Open the OntoBricks app: **Settings → Triple store → Neo4j**. The **Password** field shows a green badge **From Apps secret** and the input is disabled. Save the engine config — any persisted clear-text `password` in `global_config` is stripped server-side at save time.
+
+## Local development
+
+When `DATABRICKS_APP_PORT` is unset (running on your laptop), the password falls back to `engine_config.password` from the Settings UI. This path is **disabled in the deployed app** — the runtime check uses the platform-injected `DATABRICKS_APP_PORT` variable to detect prod.
+
+To run locally against an Aura instance, either:
+
+- Set `NEO4J_PASSWORD` in your `.env` (so the secret path is exercised in dev too), or
+- Leave it unset and enter the password once in Settings → Neo4j (persisted to your local `global_config`).
+
+## Troubleshooting
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| App logs `InfrastructureError: NEO4J_PASSWORD env var is required` at first build | Resource bound but not propagated, **or** wrong scope/key | Re-open Resources, re-bind, ensure scope+key exist via `databricks secrets list-secrets ` |
+| Settings badge stays **Local-dev fallback** in deployed app | Resource is unbound | Bind it via Apps → Resources (step 2) |
+| Connection succeeds locally but fails in deployed app | Local `.env` has the right password, prod secret does not | Verify the value: `databricks secrets get-secret ` |
+
+## Why this design
+
+- **Zero clear-text credential in `global_config`** — the save endpoint strips `password` whenever `NEO4J_PASSWORD` is set.
+- **Declarative** — the binding is part of the app's resources, reviewable in the Apps UI, with audit trails on the secret scope.
+- **No runtime call to the Secrets API** — the platform injects the value at startup; the app code reads a plain env var.
+- **No per-user secrets** — the binding is at the app level (service principal), consistent with the Lakebase OAuth pattern (`LakebaseAuth`).
diff --git a/pyproject.toml b/pyproject.toml
index 3f88375e..0f389766 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "ontobricks"
-version = "0.5.0"
+version = "0.7.0"
description = "Ontology Management Tool for Databricks"
requires-python = ">=3.10"
dependencies = [
@@ -35,6 +35,13 @@ lakebase = [
"psycopg-pool>=3.2.0",
]
+# Neo4j (Bolt / Cypher) graph DB engine — optional, opt-in via Settings.
+# Install with: ``uv sync --extra neo4j`` or ``pip install .[neo4j]``.
+# Required when Settings > Triple store > Global selects "Neo4j (Bolt)".
+neo4j = [
+ "neo4j>=5.0",
+]
+
# Ontology pitfall detection (D2KLab Ontology-Pitfalls-Detector).
# Install with: ``pip install .[pitfalls]``
# Heavy ML deps; downloaded on first use (SentenceTransformer model weights).
diff --git a/scripts/deploy.config.sh b/scripts/deploy.config.sh
index 2a17d7f3..15585fe6 100755
--- a/scripts/deploy.config.sh
+++ b/scripts/deploy.config.sh
@@ -40,7 +40,7 @@
# THE ONLY LINE YOU NEED TO CHANGE to create a new deployment.
# App names are workspace-global — pick one not already in `databricks apps list`.
# The workspace sync folder is automatically isolated per app name.
-DEFAULT_APP_NAME="ontobricks-050"
+DEFAULT_APP_NAME="ontobricks-070"
# ── 0b. Workspace constants ──────────────────────────────────────────
# Set once for your workspace. Shared across all instances deployed here.
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 5d4789c7..98b7d12c 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -394,9 +394,25 @@ if ! $NO_RUN; then
ok "app start requested"
begin_step "Start $MCP_APP_NAME"
- databricks bundle run "$MCP_APP_RESOURCE_KEY" -t "$TARGET" "${_dab_var_overrides[@]}" \
- || die "failed to start app '${MCP_APP_NAME}'. Inspect the logs: databricks apps logs ${MCP_APP_NAME}"
- ok "MCP app start requested"
+ # The MCP companion start step is observed to fail transiently with
+ # "App deployment failed unexpectedly" on the very first deploy of a
+ # fresh app — likely a race between the Apps platform reconciling
+ # the freshly-created `mcp_ontobricks_app` resource and the bundle
+ # run call. A second attempt 15 seconds later always succeeds.
+ _mcp_attempt=1
+ _mcp_max_attempts=2
+ while true; do
+ if databricks bundle run "$MCP_APP_RESOURCE_KEY" -t "$TARGET" "${_dab_var_overrides[@]}"; then
+ ok "MCP app start requested"
+ break
+ fi
+ if [ "$_mcp_attempt" -ge "$_mcp_max_attempts" ]; then
+ die "failed to start app '${MCP_APP_NAME}' after ${_mcp_max_attempts} attempts. Inspect: databricks apps logs ${MCP_APP_NAME}"
+ fi
+ info "MCP start failed (attempt $_mcp_attempt) — retrying in 15s (Apps platform race on first-deploy)..."
+ sleep 15
+ _mcp_attempt=$((_mcp_attempt + 1))
+ done
else
begin_step "Start apps (skipped)"
info "skipped per --no-run"
diff --git a/src/agents/tools/documents.py b/src/agents/tools/documents.py
index bcadd601..9782a216 100644
--- a/src/agents/tools/documents.py
+++ b/src/agents/tools/documents.py
@@ -182,7 +182,15 @@ def tool_read_document(ctx: ToolContext, *, filename: str = "", **_kwargs) -> st
"error": (
"Binary document could not be parsed. A SQL warehouse with "
"ai_parse_document access is required to read PDF, Office, or "
- "image files."
+ "image files. See docs/pr47-neo4j-demo/"
+ "ai-parse-document-prereq.md for setup. Falling back to "
+ "filename-only inference for ontology generation."
+ ),
+ "remediation": (
+ "1) Grant USE CATALOG + ALL ON SCHEMA on system.ai to the "
+ "app service principal, OR pick a SQL warehouse with "
+ "ai_parse_document enabled in the workspace. "
+ "2) Re-deploy or re-bind the sql-warehouse Apps resource."
),
}
)
diff --git a/src/api/routers/internal/dtwin.py b/src/api/routers/internal/dtwin.py
index 66c4230f..1ff72585 100644
--- a/src/api/routers/internal/dtwin.py
+++ b/src/api/routers/internal/dtwin.py
@@ -1827,6 +1827,7 @@ async def dtwin_graphql_schema(
domain.
"""
from back.core.graphql import build_schema_for_domain
+ from back.fastapi.graphql_routes import _diagnose_empty_ontology
from strawberry.printer import print_schema
domain = get_domain(session_mgr)
@@ -1839,10 +1840,25 @@ async def dtwin_graphql_schema(
properties_list = ontology.get("properties", []) or []
base_uri = ontology.get("base_uri", DEFAULT_BASE_URI)
- if not classes:
- raise ValidationError(
- "Ontology is empty — add at least one class to generate a GraphQL schema."
- )
+ # Friendly fallback: when the ontology is too thin to back a GraphQL
+ # schema, return a 200 with ``sdl=null`` + a typed ``reason``. The UI
+ # branches on ``ready`` to render an in-context hint instead of a
+ # blunt "HTTP 400" toast.
+ diag = _diagnose_empty_ontology(classes, properties_list)
+ if diag is not None:
+ reason, message = diag
+ return {
+ "success": True,
+ "ready": False,
+ "domain": display_name,
+ "sdl": None,
+ "reason": reason,
+ "message": message,
+ "stats": {
+ "classes": len(classes),
+ "properties": len(properties_list),
+ },
+ }
result = build_schema_for_domain(classes, properties_list, base_uri, display_name)
if not result:
@@ -1852,6 +1868,7 @@ async def dtwin_graphql_schema(
schema, _metadata = result
return {
"success": True,
+ "ready": True,
"domain": display_name,
"sdl": print_schema(schema),
}
diff --git a/src/api/routers/internal/settings.py b/src/api/routers/internal/settings.py
index 3540b562..f99d6cd4 100644
--- a/src/api/routers/internal/settings.py
+++ b/src/api/routers/internal/settings.py
@@ -788,6 +788,22 @@ async def get_graph_engine_lakebase_health(
return config_service.graph_engine_lakebase_health_result(session_mgr, settings)
+@router.post("/graph-engine/neo4j-test")
+async def post_graph_engine_neo4j_test(
+ session_mgr: SessionManager = Depends(get_session_manager),
+ settings: Settings = Depends(get_settings),
+):
+ """Probe Neo4j Bolt connectivity via ``driver.verify_connectivity()``.
+
+ Wires the Settings → Triple store → Neo4j "Test connection" button.
+ Uses the persisted ``engine_config`` (URI + username + encrypted) and the
+ ``NEO4J_PASSWORD`` env var injected by the bound Apps secret resource.
+ No Cypher is run — this is a Bolt protocol handshake only.
+ """
+ with map_route_errors("graph engine Neo4j connection test", logger):
+ return config_service.graph_engine_neo4j_test_result(session_mgr, settings)
+
+
@router.get("/graph-engine/uc-catalogs")
async def get_graph_engine_uc_catalogs(
session_mgr: SessionManager = Depends(get_session_manager),
diff --git a/src/back/core/graphdb/GraphDBFactory.py b/src/back/core/graphdb/GraphDBFactory.py
index 3b2eb4ea..83f1d6f0 100644
--- a/src/back/core/graphdb/GraphDBFactory.py
+++ b/src/back/core/graphdb/GraphDBFactory.py
@@ -19,6 +19,7 @@ class GraphDBFactory:
"""Construct graph DB backend instances from domain session configuration."""
LAKEBASE_AVAILABLE = False
+ NEO4J_AVAILABLE = False
def create(
self,
@@ -47,9 +48,50 @@ def create(
if engine == "lakebase":
return self._create_lakebase(domain, settings, engine_config=engine_config)
+ if engine == "neo4j":
+ return self._create_neo4j(domain, settings, engine_config=engine_config)
+
logger.warning("Unknown graph DB engine: %s", engine)
return None
+ def _create_neo4j(
+ self,
+ domain: Any,
+ settings: Optional[Any] = None,
+ *,
+ engine_config: Optional[Dict[str, Any]] = None,
+ ) -> Optional[Any]:
+ """Instantiate :class:`Neo4jStore` against the configured Bolt endpoint.
+
+ Reads ``uri``, ``database``, ``auth_method`` and credentials from
+ ``engine_config``. See :class:`back.core.graphdb.neo4j.Neo4jStore`
+ for the recognised keys.
+ """
+ try:
+ from back.core.graphdb.neo4j import NEO4J_AVAILABLE
+ from back.core.graphdb.neo4j.Neo4jStore import Neo4jStore
+ from shared.config.constants import DEFAULT_GRAPH_NAME
+ except ImportError as e:
+ logger.warning("Neo4j graph engine requires the 'neo4j' driver: %s", e)
+ return None
+
+ if not NEO4J_AVAILABLE:
+ logger.warning("Neo4j graph backend unavailable (neo4j driver not installed)")
+ return None
+
+ cfg = engine_config or {}
+ base_name = (domain.info or {}).get("name", DEFAULT_GRAPH_NAME)
+ version = getattr(domain, "current_version", "1") or "1"
+ db_name = "%s_V%s" % (base_name, version)
+ try:
+ return Neo4jStore(db_name=db_name, engine_config=cfg)
+ except (ValueError, NotImplementedError) as exc:
+ logger.warning("Neo4jStore configuration error: %s", exc)
+ return None
+ except Exception as e: # noqa: BLE001
+ logger.exception("Failed to create Neo4jStore: %s", e)
+ return None
+
def _create_lakebase(
self,
domain: Any,
@@ -230,3 +272,10 @@ def _get_factory_singleton() -> GraphDBFactory:
GraphDBFactory.LAKEBASE_AVAILABLE = bool(_LB_AVAIL)
except ImportError:
logger.debug("Lakebase graph backends not available (optional dependency)")
+
+try:
+ from back.core.graphdb.neo4j import NEO4J_AVAILABLE as _NEO4J_AVAIL # noqa: F401
+
+ GraphDBFactory.NEO4J_AVAILABLE = bool(_NEO4J_AVAIL)
+except ImportError:
+ logger.debug("Neo4j graph backend not available (optional dependency)")
diff --git a/src/back/core/graphdb/neo4j/Neo4jConnection.py b/src/back/core/graphdb/neo4j/Neo4jConnection.py
new file mode 100644
index 00000000..f3f2eb38
--- /dev/null
+++ b/src/back/core/graphdb/neo4j/Neo4jConnection.py
@@ -0,0 +1,227 @@
+"""Neo4j driver lifecycle, auth resolution, and Cypher execution helper.
+
+Carved out of :mod:`Neo4jStore` during the PR #47 review split (Benoit
+2026-06-18 — "la classe est trop grosse"). This module owns three concerns:
+
+1. **Auth resolution** — `NEO4J_PASSWORD` env var (production, populated by
+ a Databricks Apps secret resource bound in ``app.yaml``) takes priority
+ over ``engine_config['password']`` (local-dev fallback). When running
+ inside the deployed app (``DATABRICKS_APP_PORT`` is set) the env var
+ becomes mandatory and a missing value raises
+ :class:`InfrastructureError` with a clear remediation pointer.
+2. **Driver lifecycle** — lazy creation of the thread-safe ``neo4j.Driver``
+ (acts as a connection pool). One driver per ``Neo4jConnection``.
+3. **Query execution** — :meth:`run` opens a per-query session, executes
+ the Cypher, and emits one INFO log line per call summarising
+ ``rows`` + duration + a whitespace-flattened Cypher snippet (per
+ Benoit's "log the executed Cypher" review request). Bound ``params``
+ are logged at DEBUG only — they never carry credentials (auth lives
+ on the driver) but may carry build-pipeline URIs/literals.
+"""
+
+import os
+import re
+import time
+from typing import Any, Dict, List, Optional, Tuple
+
+from back.core.errors import InfrastructureError, ValidationError
+from back.core.logging import get_logger
+
+logger = get_logger(__name__)
+
+# ---------------------------------------------------------------------------
+# Guarded import — neo4j driver is an optional dependency.
+# ---------------------------------------------------------------------------
+try:
+ import neo4j as _neo4j
+except ImportError:
+ _neo4j = None # type: ignore[assignment]
+
+
+DEFAULT_DATABASE = "neo4j"
+DEFAULT_AUTH_METHOD = "basic"
+SUPPORTED_AUTH_METHODS = ("basic", "databricks_secret")
+
+# Env var fed by a Databricks Apps secret resource bound in app.yaml as
+# ``valueFrom: neo4j-password``. When set, the persisted engine_config
+# password is ignored (and stripped at save-time) — see
+# docs/pr47-neo4j-demo/secret-configuration.md.
+NEO4J_PASSWORD_ENV = "NEO4J_PASSWORD"
+
+
+def is_neo4j_password_from_secret() -> bool:
+ """True when ``NEO4J_PASSWORD`` is set in the environment.
+
+ Module-level helper so the Settings save endpoint and the UI page
+ context can ask the question without instantiating a full store.
+ """
+ return bool(os.environ.get(NEO4J_PASSWORD_ENV, "").strip())
+
+
+# Cap on the Cypher snippet logged at INFO. Beyond this size we truncate
+# (full statement is still available at DEBUG via ``Cypher params``).
+_CYPHER_LOG_MAX = 1500
+_WHITESPACE_RE = re.compile(r"\s+")
+
+
+def _normalise_cypher_for_log(cypher: str) -> str:
+ """Collapse runs of whitespace into single spaces and truncate.
+
+ Cypher in this module is assembled from multi-line f-strings; flattening
+ them keeps each log entry on a single grep-friendly line.
+ """
+ flat = _WHITESPACE_RE.sub(" ", cypher).strip()
+ if len(flat) > _CYPHER_LOG_MAX:
+ return flat[:_CYPHER_LOG_MAX] + "… (truncated)"
+ return flat
+
+
+class Neo4jConnection:
+ """Owns the Bolt driver and exposes a thin :meth:`run` for Cypher.
+
+ Parameters
+ ----------
+ uri:
+ Bolt URI (validated by the caller).
+ database:
+ Logical Neo4j database name.
+ auth_method:
+ ``"basic"`` (username + password) or ``"databricks_secret"``
+ (scope/key — reserved for a follow-up PR).
+ engine_config:
+ The raw ``engine_config`` dict. Used by :meth:`_resolve_auth`
+ for the username and the local-dev fallback password.
+ encrypted:
+ Bolt-level encryption flag (ignored when the URI scheme already
+ embeds TLS, e.g. ``neo4j+s://``).
+ """
+
+ def __init__(
+ self,
+ uri: str,
+ database: str,
+ auth_method: str,
+ engine_config: Dict[str, Any],
+ encrypted: bool = True,
+ ) -> None:
+ if _neo4j is None:
+ raise ImportError(
+ "neo4j is required for the Neo4j backend. "
+ "Install it with: pip install 'neo4j>=5.0'"
+ )
+ self._uri = uri
+ self._database = database
+ self._auth_method = auth_method
+ self._engine_config = engine_config
+ self._encrypted = encrypted
+ self._driver: Optional[Any] = None
+
+ @property
+ def database(self) -> str:
+ return self._database
+
+ @property
+ def uri(self) -> str:
+ return self._uri
+
+ def get_driver(self) -> Any:
+ """Return (lazily create) the Neo4j driver.
+
+ Neo4j's Python driver itself is a thread-safe connection pool.
+ Sessions are short-lived and created per-query in :meth:`run`.
+ """
+ if self._driver is not None:
+ return self._driver
+ auth = self._resolve_auth()
+ kwargs: Dict[str, Any] = {"auth": auth}
+ # neo4j+s:// embeds TLS — passing encrypted=True is rejected.
+ if not self._uri.startswith(("neo4j+s://", "neo4j+ssc://", "bolt+s://", "bolt+ssc://")):
+ kwargs["encrypted"] = self._encrypted
+ self._driver = _neo4j.GraphDatabase.driver(self._uri, **kwargs)
+ logger.info("Neo4j driver opened for %s (database=%s)", self._uri, self._database)
+ return self._driver
+
+ def close(self) -> None:
+ if self._driver is not None:
+ try:
+ self._driver.close()
+ except Exception as exc: # noqa: BLE001
+ logger.warning("Neo4j driver close failed: %s", exc)
+ self._driver = None
+ logger.debug("Neo4j driver closed")
+
+ @staticmethod
+ def _is_deployed_app() -> bool:
+ """True when running inside Databricks Apps (port var is set)."""
+ return bool(os.environ.get("DATABRICKS_APP_PORT"))
+
+ def _resolve_auth(self) -> Tuple[str, str]:
+ cfg = self._engine_config
+ if self._auth_method == "basic":
+ user = str(cfg.get("username") or "").strip()
+ if not user:
+ raise ValidationError(
+ "Neo4jConnection: auth_method=basic requires engine_config['username']"
+ )
+ pwd_env = os.environ.get(NEO4J_PASSWORD_ENV, "").strip()
+ pwd_cfg = str(cfg.get("password") or "")
+ if pwd_env:
+ logger.info("Neo4j credentials sourced from %s env var", NEO4J_PASSWORD_ENV)
+ return (user, pwd_env)
+ if self._is_deployed_app():
+ raise InfrastructureError(
+ "Neo4jConnection: %s env var is required in the deployed app — "
+ "declare a Databricks Apps secret resource named 'neo4j-password' "
+ "and bind it via app.yaml `valueFrom`. See "
+ "docs/pr47-neo4j-demo/secret-configuration.md."
+ % NEO4J_PASSWORD_ENV
+ )
+ if not pwd_cfg:
+ raise ValidationError(
+ "Neo4jConnection: auth_method=basic requires either the %s env var "
+ "(production) or engine_config['password'] (local dev)."
+ % NEO4J_PASSWORD_ENV
+ )
+ logger.info("Neo4j credentials sourced from engine_config (local-dev fallback)")
+ return (user, pwd_cfg)
+ if self._auth_method == "databricks_secret":
+ scope = str(cfg.get("secret_scope") or "").strip()
+ key = str(cfg.get("secret_key") or "").strip()
+ if not scope or not key:
+ raise ValidationError(
+ "Neo4jConnection: auth_method=databricks_secret requires "
+ "engine_config['secret_scope'] and ['secret_key']"
+ )
+ # TODO(PR3): resolve via Databricks secrets API. The supported
+ # production path today is the env-var-via-Apps-secret-resource
+ # mechanism handled by the ``basic`` branch above.
+ raise NotImplementedError(
+ "auth_method=databricks_secret is reserved for a follow-up PR; "
+ "use auth_method=basic with the NEO4J_PASSWORD secret resource instead."
+ )
+ raise ValidationError("Unsupported auth_method: %s" % self._auth_method)
+
+ def run(self, cypher: str, **params: Any) -> List[Dict[str, Any]]:
+ """Execute a Cypher statement against the configured database.
+
+ Returns rows as dicts. Wraps the session in a single transaction.
+ Emits one INFO log line per call with ``rows`` count, duration, and
+ a whitespace-flattened Cypher snippet so reviewers can correlate
+ UI actions with the backend query (Benoit's PR #47 2026-06-18
+ request). Bound ``params`` are logged at DEBUG only — they never
+ contain credentials (auth lives on the driver, not per-session).
+ """
+ driver = self.get_driver()
+ logger.debug("Cypher params: %s", params)
+ t0 = time.monotonic()
+ with driver.session(database=self._database) as session:
+ result = session.run(cypher, **params)
+ rows = [dict(record) for record in result]
+ duration_ms = (time.monotonic() - t0) * 1000.0
+ logger.info(
+ "Cypher (%d rows, %.1f ms): %s",
+ len(rows),
+ duration_ms,
+ _normalise_cypher_for_log(cypher),
+ )
+ return rows
diff --git a/src/back/core/graphdb/neo4j/Neo4jReadOps.py b/src/back/core/graphdb/neo4j/Neo4jReadOps.py
new file mode 100644
index 00000000..8e48f1ca
--- /dev/null
+++ b/src/back/core/graphdb/neo4j/Neo4jReadOps.py
@@ -0,0 +1,614 @@
+"""Neo4j read-side queries: statistics, entity lookup, traversal, reasoning.
+
+Carved out of :mod:`Neo4jStore` for readability (Benoit PR #47 review).
+Implements the 16+ named-query methods of the ``TripleStoreBackend``
+contract using native Cypher over the flat-triple model — every triple
+is a ``(: {subject, predicate, object})`` node.
+
+Knowledge-Graph filter primitives (``find_seed_subjects``,
+``bfs_traversal``, ``expand_entity_neighbors``) and reasoning helpers
+(``transitive_closure``, ``symmetric_expand``, ``shortest_path``) live
+here; a typed-relationship graph model would be faster but lands in a
+follow-up PR (will set ``supports_graph_model=True``).
+"""
+
+from typing import Any, Dict, List, Optional, Set
+
+from back.core.graphdb.neo4j.Neo4jConnection import Neo4jConnection
+from back.core.graphdb.neo4j.Neo4jWriteOps import sanitise_label
+from back.core.logging import get_logger
+from back.core.triplestore.constants import RDF_TYPE, RDFS_LABEL
+
+logger = get_logger(__name__)
+
+
+class Neo4jReadOps:
+ """Read queries against the flat-triple Neo4j backend."""
+
+ def __init__(self, connection: Neo4jConnection) -> None:
+ self._conn = connection
+
+ # ======================================================================
+ # Basic CRUD reads
+ # ======================================================================
+
+ def query_triples(self, table_name: str) -> List[Dict[str, str]]:
+ label = sanitise_label(table_name)
+ cypher = (
+ f"MATCH (t:`{label}`) "
+ f"RETURN t.subject AS subject, t.predicate AS predicate, t.object AS object"
+ )
+ rows = self._conn.run(cypher)
+ return [
+ {"subject": r["subject"], "predicate": r["predicate"], "object": r["object"]}
+ for r in rows
+ ]
+
+ def count_triples(self, table_name: str) -> int:
+ label = sanitise_label(table_name)
+ rows = self._conn.run(f"MATCH (t:`{label}`) RETURN count(t) AS cnt")
+ return int(rows[0]["cnt"]) if rows else 0
+
+ def table_exists(self, table_name: str) -> bool:
+ label = sanitise_label(table_name)
+ rows = self._conn.run(
+ "SHOW CONSTRAINTS YIELD name WHERE name = $cname RETURN name",
+ cname=f"triple_{label}_spo",
+ )
+ return bool(rows)
+
+ def get_status(self, table_name: str) -> Dict[str, Any]:
+ return {
+ "count": self.count_triples(table_name),
+ "last_modified": None,
+ "path": None,
+ "format": "neo4j",
+ }
+
+ # ======================================================================
+ # Statistics
+ # ======================================================================
+
+ def get_aggregate_stats(self, table_name: str) -> Dict[str, int]:
+ label = sanitise_label(table_name)
+ cypher = (
+ f"MATCH (t:`{label}`) "
+ f"RETURN count(t) AS total, "
+ f"count(DISTINCT t.subject) AS distinct_subjects, "
+ f"count(DISTINCT t.predicate) AS distinct_predicates, "
+ f"sum(CASE WHEN t.predicate = $rdf_type THEN 1 ELSE 0 END) AS type_assertion_count, "
+ f"sum(CASE WHEN t.predicate = $rdfs_label THEN 1 ELSE 0 END) AS label_count"
+ )
+ rows = self._conn.run(cypher, rdf_type=RDF_TYPE, rdfs_label=RDFS_LABEL)
+ row = rows[0] if rows else {}
+ return {
+ "total": int(row.get("total", 0) or 0),
+ "distinct_subjects": int(row.get("distinct_subjects", 0) or 0),
+ "distinct_predicates": int(row.get("distinct_predicates", 0) or 0),
+ "type_assertion_count": int(row.get("type_assertion_count", 0) or 0),
+ "label_count": int(row.get("label_count", 0) or 0),
+ }
+
+ def get_type_distribution(self, table_name: str) -> List[Dict[str, Any]]:
+ label = sanitise_label(table_name)
+ cypher = (
+ f"MATCH (t:`{label}`) WHERE t.predicate = $rdf_type "
+ f"RETURN t.object AS type_uri, count(*) AS cnt "
+ f"ORDER BY cnt DESC"
+ )
+ return self._conn.run(cypher, rdf_type=RDF_TYPE) or []
+
+ def get_predicate_distribution(self, table_name: str) -> List[Dict[str, Any]]:
+ label = sanitise_label(table_name)
+ cypher = (
+ f"MATCH (t:`{label}`) "
+ f"RETURN t.predicate AS predicate, count(*) AS cnt "
+ f"ORDER BY cnt DESC"
+ )
+ return self._conn.run(cypher) or []
+
+ # ======================================================================
+ # Entity lookup
+ # ======================================================================
+
+ def find_subjects_by_type(
+ self,
+ table_name: str,
+ type_uri: str,
+ limit: int = 50,
+ offset: int = 0,
+ search: Optional[str] = None,
+ ) -> List[str]:
+ label = sanitise_label(table_name)
+ if search:
+ cypher = (
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.predicate = $rdf_type AND t.object = $type_uri "
+ f"AND t.subject IN ("
+ f" MATCH (t2:`{label}`) "
+ f" WHERE t2.predicate <> $rdf_type AND toLower(t2.object) CONTAINS toLower($search) "
+ f" RETURN DISTINCT t2.subject"
+ f") "
+ f"RETURN DISTINCT t.subject AS subject ORDER BY subject "
+ f"SKIP $offset LIMIT $limit"
+ )
+ rows = self._conn.run(
+ cypher,
+ rdf_type=RDF_TYPE,
+ type_uri=type_uri,
+ search=search,
+ offset=int(offset),
+ limit=int(limit),
+ )
+ else:
+ cypher = (
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.predicate = $rdf_type AND t.object = $type_uri "
+ f"RETURN DISTINCT t.subject AS subject ORDER BY subject "
+ f"SKIP $offset LIMIT $limit"
+ )
+ rows = self._conn.run(
+ cypher,
+ rdf_type=RDF_TYPE,
+ type_uri=type_uri,
+ offset=int(offset),
+ limit=int(limit),
+ )
+ return [r["subject"] for r in (rows or [])]
+
+ def resolve_subject_by_id(
+ self, table_name: str, type_uri: str, id_fragment: str
+ ) -> Optional[str]:
+ label = sanitise_label(table_name)
+ cypher = (
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.predicate = $rdf_type "
+ f" AND t.object = $type_uri "
+ f" AND (t.subject ENDS WITH ('/' + $idf) OR t.subject ENDS WITH ('#' + $idf)) "
+ f"RETURN DISTINCT t.subject AS subject LIMIT 1"
+ )
+ rows = self._conn.run(
+ cypher, rdf_type=RDF_TYPE, type_uri=type_uri, idf=id_fragment
+ )
+ return rows[0]["subject"] if rows else None
+
+ def get_entity_metadata(
+ self, table_name: str, subjects: List[str]
+ ) -> List[Dict[str, str]]:
+ if not subjects:
+ return []
+ label = sanitise_label(table_name)
+ cypher_type = (
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.predicate = $rdf_type AND t.subject IN $subjects "
+ f"RETURN t.subject AS subject, t.object AS object"
+ )
+ cypher_label = (
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.predicate = $rdfs_label AND t.subject IN $subjects "
+ f"RETURN t.subject AS subject, t.object AS object"
+ )
+ type_rows = self._conn.run(cypher_type, rdf_type=RDF_TYPE, subjects=subjects) or []
+ label_rows = self._conn.run(cypher_label, rdfs_label=RDFS_LABEL, subjects=subjects) or []
+
+ types: Dict[str, str] = {}
+ for r in type_rows:
+ types.setdefault(r["subject"], r["object"])
+ labels: Dict[str, str] = {}
+ for r in label_rows:
+ labels.setdefault(r["subject"], r["object"])
+
+ return [
+ {"uri": uri, "type": types.get(uri, ""), "label": labels.get(uri, "")}
+ for uri in subjects
+ if uri in types
+ ]
+
+ def get_triples_for_subjects(
+ self, table_name: str, subjects: List[str]
+ ) -> List[Dict[str, str]]:
+ if not subjects:
+ return []
+ label = sanitise_label(table_name)
+ cypher = (
+ f"MATCH (t:`{label}`) WHERE t.subject IN $subjects "
+ f"RETURN t.subject AS subject, t.predicate AS predicate, t.object AS object"
+ )
+ return self._conn.run(cypher, subjects=subjects) or []
+
+ def get_predicates_for_type(self, table_name: str, type_uri: str) -> List[str]:
+ label = sanitise_label(table_name)
+ cypher = (
+ f"MATCH (anchor:`{label}`) "
+ f"WHERE anchor.predicate = $rdf_type AND anchor.object = $type_uri "
+ f"WITH anchor.subject AS s LIMIT 1 "
+ f"MATCH (t:`{label}`) WHERE t.subject = s "
+ f"RETURN DISTINCT t.predicate AS predicate"
+ )
+ rows = self._conn.run(cypher, rdf_type=RDF_TYPE, type_uri=type_uri) or []
+ return [r["predicate"] for r in rows]
+
+ # ======================================================================
+ # Pagination
+ # ======================================================================
+
+ def paginated_triples(
+ self,
+ table_name: str,
+ conditions: List[str],
+ limit: int,
+ offset: int,
+ ) -> List[Dict[str, str]]:
+ # *conditions* is a list of SQL WHERE fragments produced by the
+ # caller. Translating arbitrary SQL to Cypher is out of scope —
+ # only the empty-conditions case (return all triples, paginated) is
+ # supported in v1. When *conditions* is non-empty we degrade to
+ # returning the unfiltered page; callers that need filtered
+ # pagination should switch to find_subjects_by_type / find_seed_subjects.
+ label = sanitise_label(table_name)
+ if conditions:
+ logger.warning(
+ "paginated_triples received %d SQL conditions; "
+ "Neo4j backend ignores them and returns the unfiltered page. "
+ "Use find_subjects_by_type / find_seed_subjects for filtered access.",
+ len(conditions),
+ )
+ cypher = (
+ f"MATCH (t:`{label}`) "
+ f"RETURN t.subject AS subject, t.predicate AS predicate, t.object AS object "
+ f"SKIP $offset LIMIT $limit"
+ )
+ return self._conn.run(cypher, offset=int(offset), limit=int(limit)) or []
+
+ def paginated_count(self, table_name: str, conditions: List[str]) -> int:
+ # See paginated_triples — conditions are not honoured in v1.
+ if conditions:
+ logger.warning(
+ "paginated_count received %d SQL conditions; "
+ "Neo4j backend returns the unfiltered count.",
+ len(conditions),
+ )
+ return self.count_triples(table_name)
+
+ # ======================================================================
+ # Knowledge-Graph filter primitives (UI: KG filter modal, GraphQL,
+ # API explorer "triples/find"). The Cypher logic for these three
+ # methods is what Benoit asked to locate in the PR review — see
+ # docs/pr47-neo4j-demo/secret-configuration.md and the OntoBricks
+ # contributors thread on 2026-06-18.
+ # ======================================================================
+
+ def bfs_traversal(
+ self,
+ table_name: str,
+ seed_where: str,
+ depth: int,
+ search: str = "",
+ entity_type: str = "",
+ ) -> List[Dict[str, Any]]:
+ # *seed_where* is a SQL fragment. Cypher equivalent uses the
+ # structured *search* / *entity_type* parameters instead. When both
+ # structured params are empty and only seed_where is given, we
+ # cannot translate — log and return empty.
+ if not search and not entity_type:
+ if seed_where:
+ logger.warning(
+ "bfs_traversal: Neo4j backend requires structured search/entity_type "
+ "parameters; SQL seed_where fragments are not translated. "
+ "Returning empty result."
+ )
+ return []
+ seeds = self.find_seed_subjects(
+ table_name,
+ entity_type=entity_type,
+ field="any",
+ match_type="contains",
+ value=search,
+ )
+ if not seeds:
+ return []
+
+ label = sanitise_label(table_name)
+ # Reachability via property-equality joins between Triple nodes:
+ # a Triple links its subject to its object; we walk over Triple
+ # nodes hop by hop, accumulating entities (subjects + objects).
+ cypher = (
+ f"WITH $seeds AS seeds "
+ f"CALL {{ "
+ f" WITH seeds "
+ f" UNWIND seeds AS s "
+ f" RETURN s AS entity, 0 AS lvl "
+ f" UNION ALL "
+ f" WITH seeds "
+ f" MATCH (t:`{label}`) "
+ f" WHERE t.subject IN seeds "
+ f" AND t.predicate <> $rdf_type AND t.predicate <> $rdfs_label "
+ f" AND (t.object STARTS WITH 'http://' OR t.object STARTS WITH 'https://') "
+ f" RETURN DISTINCT t.object AS entity, 1 AS lvl "
+ f"}} "
+ f"WITH entity, min(lvl) AS lvl "
+ f"WHERE lvl <= $depth "
+ f"RETURN entity, lvl AS min_lvl"
+ )
+ # The query above only does 1-hop. Full BFS to *depth* > 1 would
+ # require recursive traversal — Cypher's variable-length pattern
+ # can do this natively but with the flat-triple model needs a
+ # joined pattern across Triple nodes. We expand iteratively below
+ # for arbitrary *depth* while keeping each hop bounded.
+ if depth <= 1:
+ return self._conn.run(
+ cypher, seeds=list(seeds), depth=depth, rdf_type=RDF_TYPE, rdfs_label=RDFS_LABEL
+ ) or []
+
+ # Iterative expansion for depth > 1.
+ visited: Dict[str, int] = {uri: 0 for uri in seeds}
+ frontier: Set[str] = set(seeds)
+ for lvl in range(1, depth + 1):
+ if not frontier:
+ break
+ next_frontier = self.expand_entity_neighbors(table_name, frontier)
+ new_nodes = next_frontier - set(visited.keys())
+ for n in new_nodes:
+ visited[n] = lvl
+ frontier = new_nodes
+ return [{"entity": uri, "min_lvl": lvl} for uri, lvl in visited.items()]
+
+ def find_seed_subjects(
+ self,
+ table_name: str,
+ entity_type: str = "",
+ field: str = "any",
+ match_type: str = "contains",
+ value: str = "",
+ limit: int = 0,
+ ) -> Set[str]:
+ label = sanitise_label(table_name)
+ search_label = field in ("label", "any")
+ search_id = field in ("id", "any")
+
+ # Build a Cypher predicate fragment for the chosen match_type.
+ def _match_clause(column: str, param: str) -> str:
+ if match_type == "exact":
+ return f"toLower({column}) = ${param}"
+ if match_type == "starts":
+ return f"toLower({column}) STARTS WITH ${param}"
+ if match_type == "ends":
+ return f"toLower({column}) ENDS WITH ${param}"
+ return f"toLower({column}) CONTAINS ${param}"
+
+ params: Dict[str, Any] = {
+ "rdf_type": RDF_TYPE,
+ "rdfs_label": RDFS_LABEL,
+ }
+ if value:
+ params["val"] = value.lower()
+ if entity_type:
+ params["etype"] = entity_type
+
+ cyphers: List[str] = []
+
+ if entity_type and value:
+ if search_id:
+ cyphers.append(
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.predicate = $rdf_type AND t.object = $etype "
+ f"AND {_match_clause('t.subject', 'val')} "
+ f"RETURN DISTINCT t.subject AS subject"
+ )
+ if search_label:
+ cyphers.append(
+ f"MATCH (lab:`{label}`) "
+ f"WHERE lab.predicate = $rdfs_label "
+ f"AND {_match_clause('lab.object', 'val')} "
+ f"WITH DISTINCT lab.subject AS s "
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.predicate = $rdf_type AND t.object = $etype AND t.subject = s "
+ f"RETURN DISTINCT s AS subject"
+ )
+ elif entity_type:
+ cyphers.append(
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.predicate = $rdf_type AND t.object = $etype "
+ f"RETURN DISTINCT t.subject AS subject"
+ )
+ elif value:
+ if search_label:
+ cyphers.append(
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.predicate = $rdfs_label "
+ f"AND {_match_clause('t.object', 'val')} "
+ f"RETURN DISTINCT t.subject AS subject"
+ )
+ if search_id:
+ cyphers.append(
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.predicate = $rdf_type "
+ f"AND {_match_clause('t.subject', 'val')} "
+ f"RETURN DISTINCT t.subject AS subject"
+ )
+ else:
+ return set()
+
+ if not cyphers:
+ return set()
+
+ union_sql = " UNION ".join(cyphers)
+ if limit and limit > 0:
+ union_sql = f"CALL {{ {union_sql} }} RETURN subject LIMIT {int(limit)}"
+ rows = self._conn.run(union_sql, **params) or []
+ return {r["subject"] for r in rows}
+
+ def find_subjects_by_patterns(
+ self, table_name: str, like_patterns: List[str]
+ ) -> Set[str]:
+ if not like_patterns:
+ return set()
+ label = sanitise_label(table_name)
+
+ # SQL LIKE → Cypher: '%' wildcards translate to STARTS WITH / ENDS WITH
+ # / CONTAINS depending on placement. For arbitrary patterns we fall
+ # back to a regex match.
+ clauses: List[str] = []
+ params: Dict[str, Any] = {}
+ for i, raw in enumerate(like_patterns):
+ pkey = f"p{i}"
+ params[pkey] = raw.replace("%", ".*")
+ clauses.append(f"t.subject =~ ${pkey}")
+ cypher = (
+ f"MATCH (t:`{label}`) WHERE {' OR '.join(clauses)} "
+ f"RETURN DISTINCT t.subject AS subject"
+ )
+ rows = self._conn.run(cypher, **params) or []
+ return {r["subject"] for r in rows}
+
+ def expand_entity_neighbors(
+ self, table_name: str, entity_uris: Set[str]
+ ) -> Set[str]:
+ if not entity_uris:
+ return set()
+ label = sanitise_label(table_name)
+ # Outgoing edges: where subject IN seeds AND object looks like an entity URI.
+ # Incoming edges: where object IN seeds.
+ # Both then filtered to entities that have an rdf:type assertion
+ # (real entity instances, not class or property URIs).
+ cypher = (
+ f"WITH $seeds AS seeds "
+ f"MATCH (t:`{label}`) "
+ f"WHERE (t.subject IN seeds AND t.object STARTS WITH 'http' "
+ f" AND t.predicate <> $rdf_type AND t.predicate <> $rdfs_label) "
+ f" OR (t.object IN seeds AND t.predicate <> $rdf_type AND t.predicate <> $rdfs_label) "
+ f"WITH DISTINCT (CASE WHEN t.subject IN seeds THEN t.object ELSE t.subject END) AS entity "
+ f"MATCH (ty:`{label}`) "
+ f"WHERE ty.subject = entity AND ty.predicate = $rdf_type "
+ f"RETURN DISTINCT entity"
+ )
+ rows = self._conn.run(
+ cypher,
+ seeds=list(entity_uris),
+ rdf_type=RDF_TYPE,
+ rdfs_label=RDFS_LABEL,
+ ) or []
+ return {r["entity"] for r in rows}
+
+ # ======================================================================
+ # Reasoning (transitive closure, symmetric expansion, shortest path)
+ # ======================================================================
+
+ def transitive_closure(
+ self,
+ table_name: str,
+ predicate_uri: str,
+ start_uri: Optional[str] = None,
+ max_depth: int = 20,
+ ) -> List[Dict[str, Any]]:
+ # Compute transitive closure along *predicate_uri* and return triples
+ # NOT already present as direct assertions. With the flat-triple model
+ # we self-join Triple nodes hop by hop using property equality.
+ label = sanitise_label(table_name)
+ params: Dict[str, Any] = {"pred": predicate_uri, "max_depth": int(max_depth)}
+ if start_uri:
+ params["start_uri"] = start_uri
+
+ # Build a chain of MATCH clauses up to max_depth. This is verbose but
+ # explicit; Cypher does not have recursive CTEs.
+ depth = min(max_depth, 20) # hard cap for safety
+ union_parts: List[str] = []
+ # depth=2 means start -> mid -> end (2 hops)
+ for d in range(2, depth + 1):
+ chain = "MATCH (h0:`" + label + "`)"
+ wheres = ["h0.predicate = $pred"]
+ if start_uri:
+ wheres.append("h0.subject = $start_uri")
+ for i in range(1, d):
+ chain += f", (h{i}:`{label}`)"
+ wheres.append(f"h{i}.predicate = $pred")
+ wheres.append(f"h{i-1}.object = h{i}.subject")
+ union_parts.append(
+ chain + " WHERE " + " AND ".join(wheres) +
+ f" RETURN h0.subject AS subject, h{d-1}.object AS object"
+ )
+
+ if not union_parts:
+ return []
+
+ body = " UNION ".join(union_parts)
+ cypher = (
+ f"CALL {{ {body} }} "
+ f"WITH DISTINCT subject, object "
+ f"WHERE NOT EXISTS {{ "
+ f" MATCH (ex:`{label}`) "
+ f" WHERE ex.subject = subject AND ex.predicate = $pred AND ex.object = object "
+ f"}} "
+ f"RETURN subject, $pred AS predicate, object"
+ )
+ try:
+ return self._conn.run(cypher, **params) or []
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "transitive_closure failed on %s (predicate=%s): %s",
+ table_name,
+ predicate_uri,
+ exc,
+ )
+ return []
+
+ def symmetric_expand(
+ self, table_name: str, predicate_uri: str
+ ) -> List[Dict[str, Any]]:
+ label = sanitise_label(table_name)
+ cypher = (
+ f"MATCH (t:`{label}`) WHERE t.predicate = $pred "
+ f"AND NOT EXISTS {{ "
+ f" MATCH (inv:`{label}`) "
+ f" WHERE inv.subject = t.object AND inv.predicate = $pred AND inv.object = t.subject "
+ f"}} "
+ f"RETURN t.object AS subject, $pred AS predicate, t.subject AS object"
+ )
+ try:
+ return self._conn.run(cypher, pred=predicate_uri) or []
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "symmetric_expand failed on %s (predicate=%s): %s",
+ table_name,
+ predicate_uri,
+ exc,
+ )
+ return []
+
+ def shortest_path(
+ self,
+ table_name: str,
+ source_uri: str,
+ target_uri: str,
+ max_depth: int = 10,
+ ) -> List[Dict[str, Any]]:
+ # Native Cypher shortestPath would be ideal but requires a typed-
+ # relationship graph model. With the flat-triple model we do a
+ # bounded iterative BFS and return the first path found.
+ if source_uri == target_uri:
+ return [{"hop": 0, "uri": source_uri}]
+
+ visited: Set[str] = {source_uri}
+ parent: Dict[str, str] = {}
+ frontier: Set[str] = {source_uri}
+ for depth in range(1, min(max_depth, 10) + 1):
+ next_frontier = self.expand_entity_neighbors(table_name, frontier)
+ for n in next_frontier:
+ if n in visited:
+ continue
+ for prev in frontier:
+ parent.setdefault(n, prev)
+ visited |= next_frontier
+ if target_uri in next_frontier:
+ # Reconstruct the path.
+ path_uris: List[str] = [target_uri]
+ cur = target_uri
+ while cur in parent and cur != source_uri:
+ cur = parent[cur]
+ path_uris.append(cur)
+ path_uris.reverse()
+ return [{"hop": i, "uri": uri} for i, uri in enumerate(path_uris)]
+ frontier = next_frontier - visited
+ if not frontier:
+ break
+ return []
diff --git a/src/back/core/graphdb/neo4j/Neo4jStore.py b/src/back/core/graphdb/neo4j/Neo4jStore.py
new file mode 100644
index 00000000..47a201ae
--- /dev/null
+++ b/src/back/core/graphdb/neo4j/Neo4jStore.py
@@ -0,0 +1,435 @@
+"""Neo4j graph database backend — thin façade over three composed services.
+
+Bolt-based (Cypher) flat-triple store. Triples are persisted as
+``(: {subject, predicate, object})`` nodes — a
+deliberately simple schema chosen so PR 1 demonstrates the Cypher
+integration shape without committing to a typed-node graph model (which
+lands in v2 / PR 3+). One label per logical store so Neo4j 5+ ``CREATE
+CONSTRAINT`` (which only accepts single-label patterns) works.
+
+Implementation is split across three services (extracted during the PR
+#47 review — Benoit 2026-06-18 "la classe est trop grosse"):
+
+- :class:`Neo4jConnection` — driver lifecycle, auth resolution
+ (NEO4J_PASSWORD env var first, engine_config fallback in local dev,
+ hard refusal in the deployed app without a secret resource), and the
+ single :meth:`Neo4jConnection.run` execution path that emits one INFO
+ log line per Cypher statement.
+- :class:`Neo4jWriteOps` — schema (constraint create/drop), bulk writes
+ (``UNWIND`` + ``MERGE`` / ``DETACH DELETE``), cohort wipes.
+- :class:`Neo4jReadOps` — the 16+ named-query methods of the
+ ``TripleStoreBackend`` contract: statistics, entity lookup, pagination,
+ KG-filter primitives (``find_seed_subjects`` / ``bfs_traversal`` /
+ ``expand_entity_neighbors``), and reasoning helpers (``transitive_closure``,
+ ``symmetric_expand``, ``shortest_path``).
+
+``execute_query`` deliberately raises ``NotImplementedError`` — no raw
+Cypher entry point. All writes go through ``insert_triples`` after
+ontology validation in the build pipeline (Benoit's C2 safeguard:
+"l'entrée se fait par l'ontologie").
+
+Reasoning translation (``get_query_translator``) returns a
+:class:`SWRLFlatCypherTranslator` that is currently scaffolded only —
+full SWRL → Cypher translation lands in a follow-up PR.
+"""
+
+from typing import Any, Callable, Dict, List, Optional, Set, Tuple
+
+from back.core.graphdb.GraphDBBackend import GraphDBBackend
+from back.core.graphdb.neo4j.Neo4jConnection import (
+ DEFAULT_AUTH_METHOD,
+ DEFAULT_DATABASE,
+ NEO4J_PASSWORD_ENV,
+ Neo4jConnection,
+ SUPPORTED_AUTH_METHODS,
+ _normalise_cypher_for_log, # noqa: F401 — re-exported for legacy callers/tests
+ is_neo4j_password_from_secret,
+)
+from back.core.graphdb.neo4j.Neo4jReadOps import Neo4jReadOps
+from back.core.graphdb.neo4j.Neo4jWriteOps import Neo4jWriteOps, sanitise_label
+from back.core.logging import get_logger
+from shared.config.constants import DEFAULT_GRAPH_NAME
+
+logger = get_logger(__name__)
+
+# Re-exported so callers that did ``from Neo4jStore import is_neo4j_password_from_secret``
+# (SettingsService, home.py) keep working without churn.
+__all__ = [
+ "DEFAULT_AUTH_METHOD",
+ "DEFAULT_DATABASE",
+ "NEO4J_PASSWORD_ENV",
+ "Neo4jStore",
+ "SUPPORTED_AUTH_METHODS",
+ "is_neo4j_password_from_secret",
+]
+
+
+class Neo4jStore(GraphDBBackend):
+ """Neo4j (Bolt / Cypher) graph database backend — flat triple model.
+
+ Public façade composing :class:`Neo4jConnection`, :class:`Neo4jWriteOps`,
+ and :class:`Neo4jReadOps`. Implements both the ``TripleStoreBackend``
+ and ``GraphDBBackend`` contracts.
+
+ Parameters
+ ----------
+ db_name:
+ Logical name for the triple set, used as the ``table`` label in the
+ Cypher schema (every triple node carries the single label
+ ``:``).
+ engine_config:
+ JSON dict from Settings > Graph DB > Engine Configuration. Keys:
+
+ ``uri`` (required)
+ Bolt URI, e.g. ``neo4j+s://b4810af7.databases.neo4j.io``.
+ ``database`` (default ``"neo4j"``)
+ Logical Neo4j database name on the target instance.
+ ``auth_method`` (default ``"basic"``)
+ ``"basic"`` → username + password. ``"databricks_secret"`` →
+ credentials resolved from a Databricks secret scope (PR 3,
+ deferred).
+ ``username``
+ Required when ``auth_method == "basic"``.
+ ``password``
+ Local-dev fallback when ``auth_method == "basic"``. **In the
+ deployed app** (when ``DATABRICKS_APP_PORT`` is set) the password
+ MUST come from the ``NEO4J_PASSWORD`` env var, populated via a
+ Databricks Apps secret resource bound in ``app.yaml``. The
+ persisted JSON ``password`` is ignored in prod and stripped at
+ save-time so no clear-text credential ever lands in
+ ``global_config``. See
+ ``docs/pr47-neo4j-demo/secret-configuration.md``.
+ ``secret_scope``, ``secret_key``
+ Required when ``auth_method == "databricks_secret"``.
+ ``encrypted`` (default ``True``)
+ Bolt-level encryption flag (ignored when URI is ``neo4j+s://``).
+ """
+
+ def __init__(
+ self,
+ db_name: str = DEFAULT_GRAPH_NAME,
+ engine_config: Optional[Dict[str, Any]] = None,
+ ) -> None:
+ self.db_name = db_name
+ self.engine_config: Dict[str, Any] = engine_config or {}
+ cfg = self.engine_config
+
+ uri = str(cfg.get("uri") or "").strip()
+ if not uri:
+ raise ValueError(
+ "Neo4jStore: engine_config['uri'] is required "
+ "(e.g. 'neo4j+s://.databases.neo4j.io')"
+ )
+ database = str(cfg.get("database") or DEFAULT_DATABASE).strip() or DEFAULT_DATABASE
+ auth_method = str(cfg.get("auth_method") or DEFAULT_AUTH_METHOD).strip()
+ if auth_method not in SUPPORTED_AUTH_METHODS:
+ raise ValueError(
+ "Neo4jStore: unsupported auth_method %r (allowed: %s)"
+ % (auth_method, ", ".join(SUPPORTED_AUTH_METHODS))
+ )
+ encrypted = bool(cfg.get("encrypted", True))
+
+ # Cache constructor-derived fields so the existing test suite (which
+ # reads them directly) keeps passing.
+ self._uri = uri
+ self._database = database
+ self._auth_method = auth_method
+ self._encrypted = encrypted
+
+ self._connection = Neo4jConnection(
+ uri=uri,
+ database=database,
+ auth_method=auth_method,
+ engine_config=self.engine_config,
+ encrypted=encrypted,
+ )
+ self._writes = Neo4jWriteOps(self._connection)
+ self._reads = Neo4jReadOps(self._connection)
+
+ # ======================================================================
+ # GraphDBBackend — capability flags
+ # ======================================================================
+
+ @property
+ def supports_cypher(self) -> bool:
+ return True
+
+ @property
+ def supports_graph_model(self) -> bool:
+ # PR 1: flat-triple model (single :Triple node label).
+ # Typed-node graph model is a future PR.
+ return False
+
+ @property
+ def query_dialect(self) -> str:
+ return "cypher"
+
+ # ======================================================================
+ # Connection — delegates to Neo4jConnection
+ # ======================================================================
+
+ def get_connection(self) -> Any:
+ return self._connection.get_driver()
+
+ def close(self) -> None:
+ self._connection.close()
+
+ @property
+ def _driver(self) -> Any:
+ # Back-compat: some tests patch `store._driver` directly. Surface
+ # the connection's driver attribute through this property so the
+ # old patching pattern keeps working.
+ return self._connection._driver
+
+ @_driver.setter
+ def _driver(self, value: Any) -> None:
+ self._connection._driver = value
+
+ def _run(self, cypher: str, **params: Any) -> List[Dict[str, Any]]:
+ """Back-compat passthrough — tests mock ``store._run``.
+
+ The split moved actual execution to :meth:`Neo4jConnection.run`.
+ New code in this module calls ``self._connection.run`` directly
+ (via the WriteOps / ReadOps helpers) so mocking happens at the
+ connection layer. This passthrough preserves the public API
+ surface for the few external callers that still drive Cypher
+ through the store.
+ """
+ return self._connection.run(cypher, **params)
+
+ @staticmethod
+ def _is_deployed_app() -> bool:
+ """Back-compat delegate to :meth:`Neo4jConnection._is_deployed_app`."""
+ return Neo4jConnection._is_deployed_app()
+
+ def _resolve_auth(self) -> Tuple[str, str]:
+ """Back-compat delegate to :meth:`Neo4jConnection._resolve_auth`."""
+ return self._connection._resolve_auth()
+
+ # ======================================================================
+ # GraphDBBackend — schema helpers
+ # ======================================================================
+
+ def get_node_table(self, table_name: str) -> str:
+ return sanitise_label(table_name)
+
+ def get_graph_schema(self) -> Optional[Any]:
+ # Flat model — no schema object.
+ return None
+
+ # ======================================================================
+ # GraphDBBackend — sync to/from UC Volume (Aura is remote; no-ops)
+ # ======================================================================
+
+ def sync_to_remote(self, uc_path: str, volume_service: Any) -> Tuple[bool, str]:
+ return False, "Neo4j is remote-only; no UC Volume sync"
+
+ def sync_from_remote(self, uc_path: str, volume_service: Any) -> Tuple[bool, str]:
+ return False, "Neo4j is remote-only; no UC Volume sync"
+
+ def local_path(self) -> Optional[str]:
+ return None
+
+ def remote_archive_path(self, uc_domain_path: str) -> Optional[str]:
+ return None
+
+ # ======================================================================
+ # GraphDBBackend — reasoning support
+ # ======================================================================
+
+ def get_query_translator(self, table_name: str = "") -> Any:
+ """Return the SWRL/rule translator for this engine.
+
+ Returns a :class:`SWRLFlatCypherTranslator` — currently scaffolded
+ (every translation returns ``None``), so reasoning on Neo4j
+ reports zero violations / zero inferences instead of crashing.
+ Full SWRL → Cypher translation is a follow-up PR.
+ """
+ from back.core.reasoning.SWRLFlatCypherTranslator import (
+ SWRLFlatCypherTranslator,
+ )
+
+ return SWRLFlatCypherTranslator(node_label=self.get_node_table(table_name))
+
+ # ======================================================================
+ # TripleStoreBackend — write delegators (→ Neo4jWriteOps)
+ # ======================================================================
+
+ def create_table(self, table_name: str) -> None:
+ return self._writes.create_table(table_name)
+
+ def drop_table(self, table_name: str) -> None:
+ return self._writes.drop_table(table_name)
+
+ def insert_triples(
+ self,
+ table_name: str,
+ triples: List[Dict[str, str]],
+ batch_size: int = 2000,
+ on_progress: Optional[Callable[[int, int], None]] = None,
+ ) -> int:
+ return self._writes.insert_triples(table_name, triples, batch_size, on_progress)
+
+ def delete_triples(
+ self,
+ table_name: str,
+ triples: List[Dict[str, str]],
+ batch_size: int = 2000,
+ on_progress: Optional[Callable[[int, int], None]] = None,
+ ) -> int:
+ return self._writes.delete_triples(table_name, triples, batch_size, on_progress)
+
+ def optimize_table(self, table_name: str) -> None:
+ return self._writes.optimize_table(table_name)
+
+ def delete_cohort_triples(
+ self,
+ table_name: str,
+ cohort_uri_prefix: str,
+ in_cohort_predicate: str,
+ ) -> int:
+ return self._writes.delete_cohort_triples(
+ table_name, cohort_uri_prefix, in_cohort_predicate
+ )
+
+ def execute_query(self, query: str) -> List[Dict[str, Any]]:
+ # Deliberate: no raw Cypher entry point. All writes go through
+ # the build pipeline after ontology validation (C2 safeguard).
+ raise NotImplementedError(
+ "Neo4jStore does not expose raw Cypher execution. "
+ "Use the named query methods on TripleStoreBackend instead."
+ )
+
+ # ======================================================================
+ # TripleStoreBackend — read delegators (→ Neo4jReadOps)
+ # ======================================================================
+
+ def query_triples(self, table_name: str) -> List[Dict[str, str]]:
+ return self._reads.query_triples(table_name)
+
+ def count_triples(self, table_name: str) -> int:
+ return self._reads.count_triples(table_name)
+
+ def table_exists(self, table_name: str) -> bool:
+ return self._reads.table_exists(table_name)
+
+ def get_status(self, table_name: str) -> Dict[str, Any]:
+ return self._reads.get_status(table_name)
+
+ def get_aggregate_stats(self, table_name: str) -> Dict[str, int]:
+ return self._reads.get_aggregate_stats(table_name)
+
+ def get_type_distribution(self, table_name: str) -> List[Dict[str, Any]]:
+ return self._reads.get_type_distribution(table_name)
+
+ def get_predicate_distribution(self, table_name: str) -> List[Dict[str, Any]]:
+ return self._reads.get_predicate_distribution(table_name)
+
+ def find_subjects_by_type(
+ self,
+ table_name: str,
+ type_uri: str,
+ limit: int = 50,
+ offset: int = 0,
+ search: Optional[str] = None,
+ ) -> List[str]:
+ return self._reads.find_subjects_by_type(
+ table_name, type_uri, limit=limit, offset=offset, search=search
+ )
+
+ def resolve_subject_by_id(
+ self, table_name: str, type_uri: str, id_fragment: str
+ ) -> Optional[str]:
+ return self._reads.resolve_subject_by_id(table_name, type_uri, id_fragment)
+
+ def get_entity_metadata(
+ self, table_name: str, subjects: List[str]
+ ) -> List[Dict[str, str]]:
+ return self._reads.get_entity_metadata(table_name, subjects)
+
+ def get_triples_for_subjects(
+ self, table_name: str, subjects: List[str]
+ ) -> List[Dict[str, str]]:
+ return self._reads.get_triples_for_subjects(table_name, subjects)
+
+ def get_predicates_for_type(self, table_name: str, type_uri: str) -> List[str]:
+ return self._reads.get_predicates_for_type(table_name, type_uri)
+
+ def paginated_triples(
+ self,
+ table_name: str,
+ conditions: List[str],
+ limit: int,
+ offset: int,
+ ) -> List[Dict[str, str]]:
+ return self._reads.paginated_triples(table_name, conditions, limit, offset)
+
+ def paginated_count(self, table_name: str, conditions: List[str]) -> int:
+ return self._reads.paginated_count(table_name, conditions)
+
+ def bfs_traversal(
+ self,
+ table_name: str,
+ seed_where: str,
+ depth: int,
+ search: str = "",
+ entity_type: str = "",
+ ) -> List[Dict[str, Any]]:
+ return self._reads.bfs_traversal(
+ table_name, seed_where, depth, search=search, entity_type=entity_type
+ )
+
+ def find_seed_subjects(
+ self,
+ table_name: str,
+ entity_type: str = "",
+ field: str = "any",
+ match_type: str = "contains",
+ value: str = "",
+ limit: int = 0,
+ ) -> Set[str]:
+ return self._reads.find_seed_subjects(
+ table_name,
+ entity_type=entity_type,
+ field=field,
+ match_type=match_type,
+ value=value,
+ limit=limit,
+ )
+
+ def find_subjects_by_patterns(
+ self, table_name: str, like_patterns: List[str]
+ ) -> Set[str]:
+ return self._reads.find_subjects_by_patterns(table_name, like_patterns)
+
+ def expand_entity_neighbors(
+ self, table_name: str, entity_uris: Set[str]
+ ) -> Set[str]:
+ return self._reads.expand_entity_neighbors(table_name, entity_uris)
+
+ def transitive_closure(
+ self,
+ table_name: str,
+ predicate_uri: str,
+ start_uri: Optional[str] = None,
+ max_depth: int = 20,
+ ) -> List[Dict[str, Any]]:
+ return self._reads.transitive_closure(
+ table_name, predicate_uri, start_uri=start_uri, max_depth=max_depth
+ )
+
+ def symmetric_expand(
+ self, table_name: str, predicate_uri: str
+ ) -> List[Dict[str, Any]]:
+ return self._reads.symmetric_expand(table_name, predicate_uri)
+
+ def shortest_path(
+ self,
+ table_name: str,
+ source_uri: str,
+ target_uri: str,
+ max_depth: int = 10,
+ ) -> List[Dict[str, Any]]:
+ return self._reads.shortest_path(
+ table_name, source_uri, target_uri, max_depth=max_depth
+ )
diff --git a/src/back/core/graphdb/neo4j/Neo4jWriteOps.py b/src/back/core/graphdb/neo4j/Neo4jWriteOps.py
new file mode 100644
index 00000000..a884580c
--- /dev/null
+++ b/src/back/core/graphdb/neo4j/Neo4jWriteOps.py
@@ -0,0 +1,156 @@
+"""Neo4j write/CRUD operations on the flat-triple model.
+
+Owns schema management (constraint create/drop), bulk write paths
+(``UNWIND`` + ``MERGE`` for inserts, ``UNWIND`` + ``MATCH`` + ``DETACH
+DELETE`` for deletes), and cohort wipes. Receives a
+:class:`Neo4jConnection` (composition) so all queries go through the
+shared logging / auth layer.
+
+Schema convention: one Cypher label per logical store (sanitised from
+the table name). Multi-label compound patterns (e.g. ``:Triple:``)
+are deliberately avoided because Neo4j 5+ rejects them in
+``CREATE CONSTRAINT``.
+"""
+
+from typing import Any, Callable, Dict, List, Optional
+
+from back.core.graphdb.neo4j.Neo4jConnection import Neo4jConnection
+from back.core.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+def sanitise_label(table_name: str) -> str:
+ """Neo4j labels are case-sensitive identifiers; sanitise to ``[A-Za-z0-9_]``."""
+ return "".join(c if c.isalnum() or c == "_" else "_" for c in table_name)
+
+
+class Neo4jWriteOps:
+ """Schema + CRUD writes for the flat-triple Neo4j backend."""
+
+ def __init__(self, connection: Neo4jConnection) -> None:
+ self._conn = connection
+
+ # ----------------------------------------------------------------------
+ # Schema (constraint lifecycle)
+ # ----------------------------------------------------------------------
+
+ def create_table(self, table_name: str) -> None:
+ label = sanitise_label(table_name)
+ cypher = (
+ f"CREATE CONSTRAINT triple_{label}_spo IF NOT EXISTS "
+ f"FOR (t:`{label}`) "
+ f"REQUIRE (t.subject, t.predicate, t.object) IS UNIQUE"
+ )
+ self._conn.run(cypher)
+ logger.info("Created Neo4j triple label: %s", label)
+
+ def drop_table(self, table_name: str) -> None:
+ label = sanitise_label(table_name)
+ self._conn.run(f"DROP CONSTRAINT triple_{label}_spo IF EXISTS")
+ self._conn.run(f"MATCH (t:`{label}`) DETACH DELETE t")
+ logger.info("Dropped Neo4j triple label: %s", label)
+
+ def optimize_table(self, table_name: str) -> None:
+ # Neo4j has no manual VACUUM/OPTIMIZE; the indexer runs online.
+ return None
+
+ # ----------------------------------------------------------------------
+ # Bulk writes
+ # ----------------------------------------------------------------------
+
+ def insert_triples(
+ self,
+ table_name: str,
+ triples: List[Dict[str, str]],
+ batch_size: int = 2000,
+ on_progress: Optional[Callable[[int, int], None]] = None,
+ ) -> int:
+ if not triples:
+ return 0
+ label = sanitise_label(table_name)
+ total = 0
+ cypher = (
+ f"UNWIND $rows AS r "
+ f"MERGE (t:`{label}` {{subject: r.subject, predicate: r.predicate, object: r.object}})"
+ )
+ for i in range(0, len(triples), batch_size):
+ batch = triples[i : i + batch_size]
+ rows = [
+ {
+ "subject": t.get("subject", ""),
+ "predicate": t.get("predicate", ""),
+ "object": t.get("object", ""),
+ }
+ for t in batch
+ ]
+ self._conn.run(cypher, rows=rows)
+ total += len(batch)
+ if on_progress:
+ on_progress(total, len(triples))
+ logger.info("Inserted %d triples into Neo4j label %s", total, label)
+ return total
+
+ def delete_triples(
+ self,
+ table_name: str,
+ triples: List[Dict[str, str]],
+ batch_size: int = 2000,
+ on_progress: Optional[Callable[[int, int], None]] = None,
+ ) -> int:
+ if not triples:
+ return 0
+ label = sanitise_label(table_name)
+ deleted = 0
+ cypher = (
+ f"UNWIND $rows AS r "
+ f"MATCH (t:`{label}` {{subject: r.subject, predicate: r.predicate, object: r.object}}) "
+ f"DETACH DELETE t"
+ )
+ for i in range(0, len(triples), batch_size):
+ batch = triples[i : i + batch_size]
+ rows = [
+ {
+ "subject": t.get("subject", ""),
+ "predicate": t.get("predicate", ""),
+ "object": t.get("object", ""),
+ }
+ for t in batch
+ ]
+ self._conn.run(cypher, rows=rows)
+ deleted += len(batch)
+ if on_progress:
+ on_progress(deleted, len(triples))
+ logger.info("Deleted %d triples from Neo4j label %s", deleted, label)
+ return deleted
+
+ def delete_cohort_triples(
+ self,
+ table_name: str,
+ cohort_uri_prefix: str,
+ in_cohort_predicate: str,
+ ) -> int:
+ if not cohort_uri_prefix:
+ return 0
+ label = sanitise_label(table_name)
+ cypher = (
+ f"MATCH (t:`{label}`) "
+ f"WHERE t.subject STARTS WITH $prefix "
+ f" OR (t.predicate = $in_pred AND t.object STARTS WITH $prefix) "
+ f"WITH t LIMIT 100000 "
+ f"DETACH DELETE t "
+ f"RETURN count(t) AS deleted"
+ )
+ try:
+ rows = self._conn.run(
+ cypher, prefix=cohort_uri_prefix, in_pred=in_cohort_predicate
+ )
+ return int(rows[0].get("deleted", 0)) if rows else 0
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "delete_cohort_triples failed on %s (%s): %s",
+ table_name,
+ cohort_uri_prefix,
+ exc,
+ )
+ return 0
diff --git a/src/back/core/graphdb/neo4j/__init__.py b/src/back/core/graphdb/neo4j/__init__.py
new file mode 100644
index 00000000..bc76ddc8
--- /dev/null
+++ b/src/back/core/graphdb/neo4j/__init__.py
@@ -0,0 +1,45 @@
+"""Neo4j graph database backend.
+
+Cypher-based, remote-only (Neo4j Aura / self-hosted Neo4j). Bolt protocol
+via the official ``neo4j`` Python driver.
+
+Public API (split during the PR #47 review — Benoit 2026-06-18):
+
+- :class:`Neo4jStore` — ``GraphDBBackend`` implementation. Thin façade
+ composing the three services below.
+- :class:`Neo4jConnection` — driver lifecycle, auth resolution, single
+ Cypher execution entry point (with INFO-level logging).
+- :class:`Neo4jWriteOps` — schema + bulk write paths.
+- :class:`Neo4jReadOps` — statistics, entity lookup, KG-filter primitives,
+ reasoning helpers.
+- :func:`is_neo4j_password_from_secret` — module helper telling the
+ Settings layer whether the runtime sources the Bolt password from the
+ ``NEO4J_PASSWORD`` env var (Databricks Apps secret resource) or from
+ the persisted ``engine_config`` (local-dev fallback).
+"""
+
+try:
+ import neo4j as _neo4j # noqa: F401
+ NEO4J_AVAILABLE = True
+except ImportError:
+ NEO4J_AVAILABLE = False
+
+if NEO4J_AVAILABLE:
+ from back.core.graphdb.neo4j.Neo4jConnection import ( # noqa: F401
+ Neo4jConnection,
+ is_neo4j_password_from_secret,
+ )
+ from back.core.graphdb.neo4j.Neo4jReadOps import Neo4jReadOps # noqa: F401
+ from back.core.graphdb.neo4j.Neo4jStore import Neo4jStore # noqa: F401
+ from back.core.graphdb.neo4j.Neo4jWriteOps import Neo4jWriteOps # noqa: F401
+
+ __all__ = [
+ "NEO4J_AVAILABLE",
+ "Neo4jConnection",
+ "Neo4jReadOps",
+ "Neo4jStore",
+ "Neo4jWriteOps",
+ "is_neo4j_password_from_secret",
+ ]
+else:
+ __all__ = ["NEO4J_AVAILABLE"]
diff --git a/src/back/core/reasoning/SWRLFlatCypherTranslator.py b/src/back/core/reasoning/SWRLFlatCypherTranslator.py
new file mode 100644
index 00000000..14ca5114
--- /dev/null
+++ b/src/back/core/reasoning/SWRLFlatCypherTranslator.py
@@ -0,0 +1,125 @@
+"""Translate SWRL rules to Cypher for the flat-triple Neo4j model.
+
+**STATUS: scaffolding only.** This class exists so the architecture is in
+place (``GraphDBBackend.get_query_translator`` returns this for Cypher
+flat-model engines) and so the reasoning UI does not crash when Neo4j is
+the active engine. **Actual SWRL → Cypher translation is not implemented
+yet** — every method here returns ``None`` and logs a clear warning.
+
+Why this is scaffolded rather than fully implemented:
+
+- The SQL counterpart (:class:`SWRLSQLTranslator`) is ~730 lines of
+ careful logic for builtins, negation, variable bindings, arity-1 vs
+ arity-2 atoms, IRI resolution, and antecedent-vs-consequent assembly.
+ A faithful Cypher port is its own piece of work, deserving a dedicated
+ PR with its own test suite — not bundled into the engine-skeleton PR.
+- Falling back to ``None`` is what the reasoning engine treats as
+ "no work to do", so the UI surfaces "0 violations / 0 inferences"
+ cleanly rather than crashing.
+
+When the dedicated PR lands it should mirror the public interface
+below — same method names, same return types — so callers do not change.
+"""
+
+from typing import Any, Dict, Optional
+
+from back.core.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class SWRLFlatCypherTranslator:
+ """Cypher counterpart of :class:`SWRLSQLTranslator` — scaffolded.
+
+ Parameters
+ ----------
+ node_label:
+ Neo4j label suffix used for the per-store triple nodes, e.g.
+ ``""``. The full triple pattern is
+ ``(:Triple:{node_label} {subject, predicate, object})``.
+ """
+
+ def __init__(self, node_label: str = "") -> None:
+ self.node_label = node_label
+
+ # ------------------------------------------------------------------
+ # Public interface — mirrors SWRLSQLTranslator.
+ # All methods return None for now (graceful no-op).
+ # ------------------------------------------------------------------
+
+ def build_violation_cypher(
+ self, table: str, params: Dict[str, Any]
+ ) -> Optional[str]:
+ """Build Cypher that finds subjects violating a SWRL rule.
+
+ Returns ``None`` — Cypher SWRL violation queries are not
+ translated in this version. Reasoning on Neo4j will report
+ zero violations until the dedicated translator PR lands.
+ """
+ logger.warning(
+ "SWRLFlatCypherTranslator.build_violation_cypher: "
+ "SWRL→Cypher translation is not implemented yet. "
+ "Returning None (rule produces no violations on Neo4j)."
+ )
+ return None
+
+ def build_antecedent_count_cypher(
+ self, table: str, params: Dict[str, Any]
+ ) -> Optional[str]:
+ """Cypher that counts how often a SWRL antecedent matches.
+
+ Returns ``None`` — see class docstring.
+ """
+ logger.warning(
+ "SWRLFlatCypherTranslator.build_antecedent_count_cypher: "
+ "not implemented yet. Returning None."
+ )
+ return None
+
+ def build_materialization_cypher(
+ self, table: str, params: Dict[str, Any]
+ ) -> Optional[str]:
+ """Cypher that materialises inferred triples produced by a rule.
+
+ Returns ``None`` — see class docstring.
+ """
+ logger.warning(
+ "SWRLFlatCypherTranslator.build_materialization_cypher: "
+ "not implemented yet. Returning None (no inferences materialised)."
+ )
+ return None
+
+ def build_inference_cypher(
+ self, table: str, params: Dict[str, Any]
+ ) -> Optional[str]:
+ """Alias / variant of :meth:`build_materialization_cypher`.
+
+ Returns ``None`` — see class docstring.
+ """
+ logger.warning(
+ "SWRLFlatCypherTranslator.build_inference_cypher: "
+ "not implemented yet. Returning None."
+ )
+ return None
+
+ # ------------------------------------------------------------------
+ # Compatibility shims — the reasoning engine calls the SQL names.
+ # Forward them to the Cypher methods so the engine can use either
+ # translator without branching.
+ # ------------------------------------------------------------------
+
+ def build_violation_sql(self, table: str, params: Dict[str, Any]) -> Optional[str]:
+ return self.build_violation_cypher(table, params)
+
+ def build_antecedent_count_sql(
+ self, table: str, params: Dict[str, Any]
+ ) -> Optional[str]:
+ return self.build_antecedent_count_cypher(table, params)
+
+ def build_materialization_sql(
+ self, table: str, params: Dict[str, Any]
+ ) -> Optional[str]:
+ return self.build_materialization_cypher(table, params)
+
+ def build_inference_sql(self, table: str, params: Dict[str, Any]) -> Optional[str]:
+ return self.build_inference_cypher(table, params)
diff --git a/src/back/fastapi/graphql_routes.py b/src/back/fastapi/graphql_routes.py
index 98be2cd8..ba64eafa 100644
--- a/src/back/fastapi/graphql_routes.py
+++ b/src/back/fastapi/graphql_routes.py
@@ -162,8 +162,43 @@ def _load_domain_from_registry(domain_name, session_mgr, settings, *, external=F
return domain
+def _diagnose_empty_ontology(classes, properties_list):
+ """Return a structured ``(reason, message)`` tuple when the ontology
+ cannot back a GraphQL schema, else ``None``.
+
+ Surfaces the SAME information the route layer used to swallow into
+ a 400 — but now consumers (UI Playground, MCP introspection) can
+ branch on ``reason`` and render a friendly state instead of a
+ blunt "HTTP 400".
+
+ Reasons:
+ - ``"no_classes"`` — ontology has zero classes (nothing to query).
+ - ``"no_properties"`` — classes exist but no relationships/data
+ properties. The auto-generated schema would have no fields to
+ traverse beyond the bare type list; we treat this as
+ "GraphQL not ready" rather than serve a degenerate schema.
+ """
+ if not classes:
+ return ("no_classes",
+ "Ontology has no classes — add classes in the Designer "
+ "before querying the knowledge graph via GraphQL.")
+ if not properties_list:
+ return ("no_properties",
+ "Ontology has classes but no relationships or data "
+ "properties — GraphQL needs at least one property to "
+ "expose meaningful query fields. Add properties in the "
+ "Designer or via OWL import.")
+ return None
+
+
def _get_schema_and_context(domain, settings):
- """Build (or retrieve cached) GraphQL schema and execution context."""
+ """Build (or retrieve cached) GraphQL schema and execution context.
+
+ Raises :class:`ValidationError` only when the ontology is genuinely
+ unusable. For the "no classes / no properties" cases the caller
+ should prefer :func:`_diagnose_empty_ontology` to render a friendly
+ state instead of catching the exception.
+ """
from back.core.graphql import build_schema_for_domain
ontology = domain.ontology or {}
@@ -172,6 +207,14 @@ def _get_schema_and_context(domain, settings):
base_uri = ontology.get("base_uri", DEFAULT_BASE_URI)
display_name = (domain.info or {}).get("name", "")
+ diag = _diagnose_empty_ontology(classes, properties_list)
+ if diag is not None:
+ # Route handlers call ``_diagnose_empty_ontology`` first and
+ # return early; if we still got here, the caller chose to keep
+ # the legacy "raise" behaviour (e.g. ``/execute``).
+ reason, message = diag
+ raise ValidationError(message, detail=reason)
+
result = build_schema_for_domain(classes, properties_list, base_uri, display_name)
if not result:
raise ValidationError(
@@ -343,12 +386,34 @@ async def graphql_sdl(
domain = _load_domain_from_registry(
domain_name, session_mgr, settings, external=is_external
)
+
+ # Friendly fallback: when the ontology is too thin to back a GraphQL
+ # schema, return a 200 with ``sdl=null`` + a typed ``reason`` + a
+ # human message. The Playground JS branches on this to render an
+ # in-context hint instead of a blunt "HTTP 400" toast.
+ ontology = domain.ontology or {}
+ classes = ontology.get("classes", []) or []
+ properties_list = ontology.get("properties", []) or []
+ diag = _diagnose_empty_ontology(classes, properties_list)
+ if diag is not None:
+ reason, message = diag
+ return JSONResponse(content={
+ "sdl": None,
+ "ready": False,
+ "reason": reason,
+ "message": message,
+ "stats": {
+ "classes": len(classes),
+ "properties": len(properties_list),
+ },
+ })
+
schema, _ = _get_schema_and_context(domain, settings)
from strawberry.printer import print_schema
sdl = print_schema(schema)
- return JSONResponse(content={"sdl": sdl})
+ return JSONResponse(content={"sdl": sdl, "ready": True})
@router.get(
diff --git a/src/back/objects/domain/SettingsService.py b/src/back/objects/domain/SettingsService.py
index 84624775..516612da 100644
--- a/src/back/objects/domain/SettingsService.py
+++ b/src/back/objects/domain/SettingsService.py
@@ -17,6 +17,7 @@
from shared.config.constants import HTTP_USER_AGENT
from shared.config.settings import Settings
from back.core.databricks import is_databricks_app
+from back.core.graphdb.neo4j.Neo4jStore import is_neo4j_password_from_secret
from back.core.helpers import (
get_databricks_client,
get_databricks_host_and_token,
@@ -1222,6 +1223,15 @@ def set_graph_engine_config_result(
_, host, token, registry_cfg = SettingsService._resolve_context(
session_mgr, settings
)
+ if is_neo4j_password_from_secret() and isinstance(config, dict) and config.get("password"):
+ # Never persist a clear-text password when the Apps secret is
+ # in place — the env var wins at runtime and this would only
+ # leak a redundant credential into global_config.
+ logger.info(
+ "Stripping engine_config['password'] before persist — "
+ "NEO4J_PASSWORD env var is the source of truth"
+ )
+ config = {k: v for k, v in config.items() if k != "password"}
ok, msg = global_config_service.set_graph_engine_config(
host, token, registry_cfg, config
)
@@ -1388,6 +1398,140 @@ def graph_engine_lakebase_health_result(
)
return out
+ @staticmethod
+ def graph_engine_neo4j_test_result(
+ session_mgr: SessionManager,
+ settings: Settings,
+ ) -> Dict[str, Any]:
+ """Probe Neo4j Bolt connectivity using the persisted ``engine_config``.
+
+ Wires the previously-placeholder "Test connection" button on Settings →
+ Triple store → Neo4j. Reads the persisted config, instantiates
+ :class:`Neo4jConnection` (this resolves auth — env var first, then
+ engine_config fallback), and calls the official driver's
+ ``verify_connectivity()`` (a lightweight Bolt handshake — no Cypher
+ is executed, no data is touched).
+
+ Returns one of:
+
+ - ``{"success": True, "ok": True, "uri": ..., "database": ...,
+ "latency_ms": ..., "credentials_source": "env var" | "engine_config"}``
+ - ``{"success": True, "ok": False, "error": ..., "category": ...}``
+ for clean error states (auth failure, DNS unresolvable, bad config) —
+ surfaces a friendly UI message without 5xx-ing the route.
+ """
+ import time as _time
+
+ from back.core.graphdb.neo4j.Neo4jConnection import (
+ NEO4J_PASSWORD_ENV,
+ Neo4jConnection,
+ is_neo4j_password_from_secret,
+ )
+
+ try:
+ _, host, token, registry_cfg = SettingsService._resolve_context(
+ session_mgr, settings
+ )
+ global_config_service.load(host, token, registry_cfg, force=True)
+ gcfg = global_config_service.get_graph_engine_config(
+ host, token, registry_cfg
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning("graph_engine_neo4j_test context failed: %s", exc)
+ raise InfrastructureError(
+ "Could not load graph engine config", detail=str(exc)
+ ) from exc
+
+ if not isinstance(gcfg, dict):
+ return {
+ "success": True,
+ "ok": False,
+ "error": "engine_config is empty — fill in URI/Username and Save first.",
+ "category": "config",
+ }
+
+ uri = str(gcfg.get("uri") or "").strip()
+ if not uri:
+ return {
+ "success": True,
+ "ok": False,
+ "error": "engine_config['uri'] is missing — set the Bolt URI in Settings → Neo4j.",
+ "category": "config",
+ }
+
+ try:
+ conn = Neo4jConnection(
+ uri=uri,
+ database=str(gcfg.get("database") or "neo4j").strip() or "neo4j",
+ auth_method=str(gcfg.get("auth_method") or "basic").strip() or "basic",
+ engine_config=gcfg,
+ encrypted=bool(gcfg.get("encrypted", True)),
+ )
+ except ValidationError as exc:
+ return {"success": True, "ok": False, "error": str(exc), "category": "config"}
+ except ImportError as exc:
+ return {
+ "success": True,
+ "ok": False,
+ "error": str(exc),
+ "category": "driver-missing",
+ }
+
+ t0 = _time.monotonic()
+ cypher_rows = None
+ try:
+ driver = conn.get_driver()
+ driver.verify_connectivity()
+ # Round-trip a trivial Cypher through the same ``_run`` path the
+ # real query stack uses — exercises session creation, Cypher
+ # execution, and the INFO log line (Benoit's PR #47 review #2).
+ cypher_rows = conn.run("RETURN 1 AS probe")
+ except InfrastructureError as exc:
+ return {
+ "success": True,
+ "ok": False,
+ "error": str(exc),
+ "category": "auth",
+ }
+ except ValidationError as exc:
+ return {
+ "success": True,
+ "ok": False,
+ "error": str(exc),
+ "category": "config",
+ }
+ except Exception as exc: # noqa: BLE001 — bolt errors don't share a base class
+ return {
+ "success": True,
+ "ok": False,
+ "error": "%s: %s" % (type(exc).__name__, exc),
+ "category": "connectivity",
+ }
+ finally:
+ try:
+ conn.close()
+ except Exception: # noqa: BLE001
+ pass
+ latency_ms = round((_time.monotonic() - t0) * 1000.0, 1)
+
+ return {
+ "success": True,
+ "ok": True,
+ "uri": uri,
+ "database": conn.database,
+ "latency_ms": latency_ms,
+ "cypher_probe": (
+ {"rows": len(cypher_rows or []), "echo": (cypher_rows[0] if cypher_rows else None)}
+ if cypher_rows is not None
+ else None
+ ),
+ "credentials_source": (
+ "env var (%s — Databricks Apps secret)" % NEO4J_PASSWORD_ENV
+ if is_neo4j_password_from_secret()
+ else "engine_config (local-dev fallback)"
+ ),
+ }
+
@staticmethod
def graph_engine_uc_catalogs_result(
session_mgr: SessionManager,
diff --git a/src/back/objects/mapping/Mapping.py b/src/back/objects/mapping/Mapping.py
index 13ed00fe..b1b3d066 100644
--- a/src/back/objects/mapping/Mapping.py
+++ b/src/back/objects/mapping/Mapping.py
@@ -347,9 +347,27 @@ def on_step(msg: str, progress_pct: int = 0) -> None:
existing_relationship_mappings=relationship_mappings,
)
+ # Distinguish two failure modes in the completion message:
+ # (a) chunk-level agent failures (LLM call raised / returned
+ # agent_result.error) — captured in ``chunk_errors``.
+ # (b) per-item "no mapping generated" — the agent ran
+ # cleanly but couldn't (or chose not to) emit a mapping
+ # for some ontology entries. Common when the source
+ # table has no columns matching the concept.
+ # The original message conflated both into "(2 chunks had
+ # errors)" which read as a bug. Split them so reviewers can
+ # tell environmental issues apart from "expected — nothing
+ # in the data to map onto".
+ unmapped_items = total_items - e_count - r_count
message = f"Completed: {e_count} entities, {r_count} relationships mapped"
+ tail_parts = []
if chunk_errors:
- message += f" ({len(chunk_errors)} chunk(s) had errors)"
+ tail_parts.append(f"{len(chunk_errors)} chunk(s) errored")
+ no_mapping_items = max(unmapped_items - 0, 0) # explicit; clarity
+ if no_mapping_items > 0:
+ tail_parts.append(f"{no_mapping_items} item(s) without a generated mapping")
+ if tail_parts:
+ message += " (" + ", ".join(tail_parts) + ")"
tm.complete_task(
task.id,
@@ -359,6 +377,8 @@ def on_step(msg: str, progress_pct: int = 0) -> None:
"total": total_items,
"success": e_count + r_count,
"failed": total_items - e_count - r_count,
+ "chunk_errors_count": len(chunk_errors),
+ "chunk_errors": chunk_errors, # NEW — exposed for UI/debug
},
"entity_mappings": all_entity_mappings,
"relationship_mappings": all_relationship_mappings,
diff --git a/src/back/objects/session/GlobalConfigService.py b/src/back/objects/session/GlobalConfigService.py
index 7e53ef15..8656afbf 100644
--- a/src/back/objects/session/GlobalConfigService.py
+++ b/src/back/objects/session/GlobalConfigService.py
@@ -263,7 +263,7 @@ def set_navbar_logo(
"""Persist the navbar logo as a ``data:`` URL (empty string clears it)."""
return self._save(host, token, registry_cfg, {"navbar_logo": data_url or ""})
- ALLOWED_GRAPH_ENGINES = ("lakebase",)
+ ALLOWED_GRAPH_ENGINES = ("lakebase", "neo4j")
def get_graph_engine(
self, host: str, token: str, registry_cfg: Dict[str, str]
diff --git a/src/front/config/menu_config.json b/src/front/config/menu_config.json
index e8062bb2..b4ac2f43 100644
--- a/src/front/config/menu_config.json
+++ b/src/front/config/menu_config.json
@@ -519,6 +519,13 @@
"icon": "bi-diagram-3",
"default": false,
"requires": null
+ },
+ {
+ "id": "neo4j",
+ "label": "Neo4j",
+ "icon": "bi-bezier2",
+ "default": false,
+ "requires": null
}
]
},
diff --git a/src/front/fastapi/dependencies.py b/src/front/fastapi/dependencies.py
index 2bb4c50a..c17bcaa3 100644
--- a/src/front/fastapi/dependencies.py
+++ b/src/front/fastapi/dependencies.py
@@ -156,13 +156,15 @@ def triplestore_page_context(domain_session, settings=None) -> dict:
"""Build the triplestore-related template context shared by dtwin and domain pages.
Returns dict with ``view_table``, ``graph_name``, ``triplestore_cache``, and
- ``graph_engine`` (currently always ``lakebase``).
+ ``graph_engine``. The engine is resolved through
+ :pymeth:`TripleStoreFactory._resolve_graph_engine`, which walks the
+ domain-override → global-setting → ``"lakebase"`` chain. ``GlobalConfigService``
+ has already validated the value against ``ALLOWED_GRAPH_ENGINES``.
"""
from back.core.helpers import effective_view_table, effective_graph_name
from back.core.triplestore.TripleStoreFactory import TripleStoreFactory
- _raw = TripleStoreFactory._resolve_graph_engine(domain_session, settings) or "lakebase"
- graph_engine = _raw if _raw == "lakebase" else "lakebase"
+ graph_engine = TripleStoreFactory._resolve_graph_engine(domain_session, settings) or "lakebase"
return {
"view_table": effective_view_table(domain_session),
diff --git a/src/front/routes/home.py b/src/front/routes/home.py
index f2b73bbe..429d9afa 100644
--- a/src/front/routes/home.py
+++ b/src/front/routes/home.py
@@ -4,8 +4,14 @@
from fastapi.responses import HTMLResponse
from front.fastapi.dependencies import templates
+from back.core.graphdb.neo4j.Neo4jStore import is_neo4j_password_from_secret
+from back.core.logging import get_logger
+from back.objects.domain.SettingsService import SettingsService
from back.objects.session import SessionManager, get_session_manager
from shared.config.constants import APP_VERSION
+from shared.config.settings import Settings, get_settings
+
+logger = get_logger(__name__)
router = APIRouter(tags=["Home"])
@@ -26,12 +32,37 @@ async def about_page(request: Request):
@router.get("/settings", response_class=HTMLResponse, include_in_schema=False)
async def settings_page(
- request: Request, session_mgr: SessionManager = Depends(get_session_manager)
+ request: Request,
+ session_mgr: SessionManager = Depends(get_session_manager),
+ settings: Settings = Depends(get_settings),
):
- """Settings page."""
+ """Settings page.
+
+ Resolves the persisted graph engine server-side so the engine selector is
+ rendered with the correct ``selected`` option on first paint — avoids the
+ "Lakebase flashes before Neo4j loads" flicker flagged in Benoit's PR #47
+ review (2026-06-18). Failure to load is non-fatal: the selector falls back
+ to the HTML default and the existing JS reconciles on the lazy-load.
+ """
user_role = getattr(request.state, "user_role", "admin")
+ graph_engine = "lakebase"
+ try:
+ result = SettingsService.get_graph_engine_result(session_mgr, settings)
+ if result.get("success"):
+ graph_engine = str(result.get("graph_engine") or "lakebase")
+ except Exception as exc: # noqa: BLE001 — degrade gracefully on Settings render
+ logger.warning(
+ "settings_page: graph engine resolution failed, falling back to default: %s",
+ exc,
+ )
return templates.TemplateResponse(
- request, "settings.html", {"user_role": user_role}
+ request,
+ "settings.html",
+ {
+ "user_role": user_role,
+ "neo4j_password_from_secret": is_neo4j_password_from_secret(),
+ "graph_engine": graph_engine,
+ },
)
diff --git a/src/front/static/config/js/settings.js b/src/front/static/config/js/settings.js
index 48b1d43b..2cb139a5 100644
--- a/src/front/static/config/js/settings.js
+++ b/src/front/static/config/js/settings.js
@@ -23,6 +23,13 @@ document.addEventListener('DOMContentLoaded', function () {
loadRegistryCacheTtl();
loadNavbarLogo();
+ // Align Lakebase sub-panel visibility with the server-rendered engine
+ // selector before any async fetch runs — fixes the "Lakebase flashes
+ // before Neo4j" flicker flagged by Benoit in the PR #47 review (the
+ // select itself is already correct from Jinja's `selected` attribute,
+ // but applyGraphDbEnginePanels otherwise waits for the lazy-load).
+ applyGraphDbEnginePanels();
+
// =====================================================================
// DATABRICKS TAB
// =====================================================================
@@ -1943,6 +1950,8 @@ document.addEventListener('DOMContentLoaded', function () {
if (sel.value === 'lakebase') {
mergeLakebasePanelIntoConfigTextarea();
+ } else if (sel.value === 'neo4j') {
+ mergeNeo4jPanelIntoConfigTextarea();
}
let parsed;
@@ -2005,6 +2014,140 @@ document.addEventListener('DOMContentLoaded', function () {
// GLOBAL SAVE BUTTON – warehouse, global prefs, CloudFetch, Graph DB
// =====================================================================
+ // ── Neo4j engine config — form ↔ textarea ───────────────────────────────
+ //
+ // Mirrors the Lakebase merge/toggle pattern. When the active engine is
+ // "neo4j" (Settings > Triple store > Global dropdown), this function
+ // reads the Neo4j config form fields from #neo4j-section and serialises
+ // them into the shared #graphEngineConfig textarea, which the existing
+ // save flow then POSTs to /settings/graph-engine-config.
+ function mergeNeo4jPanelIntoConfigTextarea() {
+ const ta = document.getElementById('graphEngineConfig');
+ if (!ta) return;
+ let o = {};
+ try { o = JSON.parse(ta.value || '{}'); } catch (_) { o = {}; }
+ if (typeof o !== 'object' || Array.isArray(o)) o = {};
+
+ const uri = (document.getElementById('neo4jUri')?.value || '').trim();
+ const database = (document.getElementById('neo4jDatabase')?.value || '').trim();
+ const authMethod = (document.getElementById('neo4jAuthMethod')?.value || 'basic').trim();
+ const encrypted = !!document.getElementById('neo4jEncrypted')?.checked;
+
+ if (uri) o.uri = uri; else delete o.uri;
+ o.database = database || 'neo4j';
+ o.auth_method = authMethod;
+ o.encrypted = encrypted;
+
+ if (authMethod === 'basic') {
+ const user = (document.getElementById('neo4jUsername')?.value || '').trim();
+ const pwdEl = document.getElementById('neo4jPassword');
+ // When the password input is disabled (Apps secret is in place),
+ // never serialise the field — the server-side env var is the
+ // source of truth and the backend strips persisted passwords.
+ const pwd = (pwdEl && !pwdEl.disabled) ? (pwdEl.value || '') : '';
+ if (user) o.username = user; else delete o.username;
+ if (pwd) o.password = pwd; else delete o.password;
+ delete o.secret_scope;
+ delete o.secret_key;
+ } else if (authMethod === 'databricks_secret') {
+ const scope = (document.getElementById('neo4jSecretScope')?.value || '').trim();
+ const key = (document.getElementById('neo4jSecretKey')?.value || '').trim();
+ if (scope) o.secret_scope = scope; else delete o.secret_scope;
+ if (key) o.secret_key = key; else delete o.secret_key;
+ delete o.username;
+ delete o.password;
+ }
+ ta.value = JSON.stringify(o, null, 2);
+ }
+
+ // Auth-method visibility toggle
+ function applyNeo4jAuthMethodVisibility() {
+ const sel = document.getElementById('neo4jAuthMethod');
+ if (!sel) return;
+ const basicFields = document.querySelectorAll('.neo4j-auth-basic');
+ const secretFields = document.querySelectorAll('.neo4j-auth-databricks-secret');
+ const isBasic = sel.value === 'basic';
+ const isSecret = sel.value === 'databricks_secret';
+ basicFields.forEach(el => el.classList.toggle('d-none', !isBasic));
+ secretFields.forEach(el => el.classList.toggle('d-none', !isSecret));
+ }
+
+ // Wire up Neo4j form field listeners — keep the textarea in sync as the
+ // user edits the panel, so the save flow always serialises fresh values.
+ [
+ 'neo4jUri', 'neo4jDatabase', 'neo4jAuthMethod',
+ 'neo4jUsername', 'neo4jPassword',
+ 'neo4jSecretScope', 'neo4jSecretKey',
+ 'neo4jEncrypted',
+ ].forEach(id => {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.addEventListener('input', mergeNeo4jPanelIntoConfigTextarea);
+ el.addEventListener('change', mergeNeo4jPanelIntoConfigTextarea);
+ });
+ document.getElementById('neo4jAuthMethod')?.addEventListener('change', applyNeo4jAuthMethodVisibility);
+ // Initial render — apply auth-method visibility on page load.
+ applyNeo4jAuthMethodVisibility();
+
+ // Test-connection button — POSTs to /settings/graph-engine/neo4j-test which
+ // runs a Bolt protocol handshake (driver.verify_connectivity()) using the
+ // persisted engine_config + NEO4J_PASSWORD env var. No Cypher is executed.
+ document.getElementById('btnTestNeo4jConnection')?.addEventListener('click', async function () {
+ const btn = this;
+ const result = document.getElementById('neo4jTestResult');
+ if (!result) return;
+ const origHtml = btn.innerHTML;
+ btn.disabled = true;
+ btn.innerHTML = ' Testing…';
+ result.className = 'alert alert-info mt-3 small';
+ result.classList.remove('d-none');
+ result.textContent = 'Sending Bolt handshake…';
+ try {
+ // Save the current panel state first so the test uses the values
+ // currently in the form, not just what was persisted.
+ mergeNeo4jPanelIntoConfigTextarea();
+ const ta = document.getElementById('graphEngineConfig');
+ let parsed = {};
+ try { parsed = JSON.parse(ta?.value || '{}'); } catch (_) { parsed = {}; }
+ await fetch('/settings/graph-engine-config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ graph_engine_config: parsed }),
+ });
+ const resp = await fetch('/settings/graph-engine/neo4j-test', {
+ method: 'POST',
+ credentials: 'same-origin',
+ });
+ const j = await resp.json();
+ if (j.ok) {
+ result.className = 'alert alert-success mt-3 small';
+ const probe = j.cypher_probe
+ ? ' · RETURN 1 AS probe echoed ' +
+ j.cypher_probe.rows + ' row(s) — Cypher path live.'
+ : '';
+ result.innerHTML =
+ ' ' +
+ 'Connected to ' + j.uri + ' ' +
+ '(database ' + j.database + ') in ' + j.latency_ms + ' ms · ' +
+ 'credentials from ' + j.credentials_source + ' .' + probe;
+ } else {
+ result.className = 'alert alert-danger mt-3 small';
+ const cat = j.category ? ' ' + j.category + ' ' : '';
+ result.innerHTML =
+ ' ' +
+ 'Test failed ' + cat + ': ' +
+ (j.error || j.message || 'Unknown error');
+ }
+ } catch (e) {
+ result.className = 'alert alert-danger mt-3 small';
+ result.textContent = 'Test failed: ' + (e.message || e);
+ } finally {
+ btn.disabled = false;
+ btn.innerHTML = origHtml;
+ }
+ });
+
document.querySelectorAll('.btn-save-settings').forEach(saveBtn => saveBtn.addEventListener('click', async function () {
const btn = this;
btn.disabled = true;
diff --git a/src/front/static/domain/js/domain-validation.js b/src/front/static/domain/js/domain-validation.js
index 2c3e549b..cc13249f 100644
--- a/src/front/static/domain/js/domain-validation.js
+++ b/src/front/static/domain/js/domain-validation.js
@@ -450,10 +450,46 @@ function updateDtwinCard(data) {
}
}
- // Graph DB card — Lakebase details
+ // Graph DB card — render engine-aware title and architecture.
+ //
+ // `dt.graph_engine` is the engine recorded on the domain at build time and
+ // can be stale relative to the active global engine. Reconcile unconditionally
+ // against `/settings/graph-engine`.
var eng = dt.graph_engine || 'lakebase';
- var titleGraph = document.getElementById('psDtGraphBackendTitle');
- if (titleGraph) titleGraph.textContent = 'Graph DB (Lakebase)';
+ var engineLabels = {
+ 'lakebase': 'Graph DB (Lakebase)',
+ 'neo4j': 'Graph DB (Neo4j)'
+ };
+
+ function _psRenderEngineUi(activeEng) {
+ var container = document.getElementById('psDtLakebaseDetails');
+ var titleEl = document.getElementById('psDtGraphBackendTitle');
+ var lkIcon = document.querySelector('#psDtGraphCard .dt-arch-icon-lakebase-img');
+ var syncRow = document.getElementById('psDtLakebaseSyncedUcRow');
+ var boltRow = document.getElementById('psDtNeo4jBoltCard');
+ var graphFn = document.getElementById('psDtLakebaseFullName');
+ if (container) container.classList.remove('d-none');
+ if (titleEl) titleEl.textContent = engineLabels[activeEng] || ('Graph DB (' + activeEng + ')');
+ if (activeEng === 'neo4j') {
+ if (syncRow) syncRow.classList.add('d-none');
+ if (boltRow) boltRow.classList.remove('d-none');
+ if (lkIcon) lkIcon.classList.add('d-none');
+ if (graphFn) graphFn.textContent = (dt.graph_name || 'Knowledge Graph');
+ } else {
+ if (syncRow) syncRow.classList.remove('d-none');
+ if (boltRow) boltRow.classList.add('d-none');
+ if (lkIcon) lkIcon.classList.remove('d-none');
+ }
+ }
+ _psRenderEngineUi(eng);
+ fetch('/settings/graph-engine', { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (data) {
+ var globalEng = data && data.graph_engine;
+ if (!globalEng || globalEng === eng) return;
+ _psRenderEngineUi(globalEng);
+ })
+ .catch(function () { /* leave fallback in place */ });
var graphCard = document.getElementById('psDtGraphCard');
if (graphCard) {
@@ -462,9 +498,6 @@ function updateDtwinCard(data) {
else if (dt.lakebase_table_exists === false) graphCard.classList.add('border-danger');
}
- var lkDetails = document.getElementById('psDtLakebaseDetails');
- if (lkDetails) lkDetails.classList.toggle('d-none', eng !== 'lakebase');
-
if (eng === 'lakebase') {
var psDb = document.getElementById('psDtLakebaseDatabase');
var psSch = document.getElementById('psDtLakebaseSchema');
diff --git a/src/front/static/query/js/query-graphql.js b/src/front/static/query/js/query-graphql.js
index 21a83f50..af01f36a 100644
--- a/src/front/static/query/js/query-graphql.js
+++ b/src/front/static/query/js/query-graphql.js
@@ -123,7 +123,27 @@ const GraphQLPlayground = (() => {
);
if (!schemaResp.ok) {
const errData = await schemaResp.json().catch(() => ({}));
- throw new Error(errData.detail || `HTTP ${schemaResp.status}`);
+ throw new Error(errData.detail || errData.message || `HTTP ${schemaResp.status}`);
+ }
+ // New v0.7 friendly fallback: 200 with ready:false when the
+ // ontology is too thin to back a schema (no classes, no
+ // properties). Show an in-context hint instead of a toast.
+ const schemaData = await schemaResp.clone().json().catch(() => ({}));
+ if (schemaData && schemaData.ready === false) {
+ _hideAll();
+ const msg = _el('graphqlErrorMsg');
+ if (msg) {
+ const stats = schemaData.stats || {};
+ msg.innerHTML =
+ 'GraphQL not ready — ' +
+ (schemaData.message || 'ontology missing classes or properties.') +
+ 'Ontology has ' +
+ (stats.classes || 0) + ' class(es), ' +
+ (stats.properties || 0) + ' propert(ies). ' +
+ 'Reason: ' + (schemaData.reason || 'unknown') + ' ';
+ }
+ _show('graphqlError');
+ return;
}
} catch (err) {
_hideAll();
diff --git a/src/front/static/query/js/query-sync.js b/src/front/static/query/js/query-sync.js
index e68da3a6..78d6c26b 100644
--- a/src/front/static/query/js/query-sync.js
+++ b/src/front/static/query/js/query-sync.js
@@ -152,17 +152,59 @@ function _applyBuildGraphEngineUi(dtExist) {
if (fnLk) fnLk.classList.remove('d-none');
var title = document.getElementById('dtGraphBackendTitle');
+ var labels = { 'lakebase': 'Graph DB (Lakebase)', 'neo4j': 'Graph DB (Neo4j)' };
if (title) {
- title.textContent = eng === 'lakebase' ? 'Graph DB (Lakebase)' : 'Graph DB Digital Twin';
+ title.textContent = labels[eng] || 'Graph DB Digital Twin';
}
+ // Toggle the post-Triple-Store cards (Sync + Graph DB) based on engine.
+ // The `dtLakebaseDetails` container wraps both the Sync card and the
+ // Graph DB card. On Lakebase, both show. On Neo4j, the Sync card is
+ // hidden (no UC-synced table on the Neo4j path) but the Graph DB card
+ // remains visible with engine-aware label and metadata.
+ function _renderEngineUi(activeEng) {
+ var container = document.getElementById('dtLakebaseDetails');
+ var titleEl = document.getElementById('dtGraphBackendTitle');
+ var lkIcon = document.querySelector('#dtGraphCard .dt-arch-icon-lakebase-img');
+ var syncRow = document.getElementById('dtLakebaseSyncedUcRow');
+ var boltRow = document.getElementById('dtNeo4jBoltCard');
+ var lkBuild = document.getElementById('dtLakebaseBuildNote');
+ var graphFn = document.getElementById('dtLakebaseFullName');
+ if (container) container.classList.remove('d-none');
+ if (titleEl) titleEl.textContent = labels[activeEng] || ('Graph DB (' + activeEng + ')');
+ if (activeEng === 'neo4j') {
+ // Show the Bolt writer card, hide the Lakebase Sync card + build note + icon
+ if (syncRow) syncRow.classList.add('d-none');
+ if (boltRow) boltRow.classList.remove('d-none');
+ if (lkBuild) lkBuild.classList.add('d-none');
+ if (lkIcon) lkIcon.classList.add('d-none');
+ if (graphFn) graphFn.textContent = (cfg.graph_name || 'Knowledge Graph');
+ } else {
+ if (syncRow) syncRow.classList.remove('d-none');
+ if (boltRow) boltRow.classList.add('d-none');
+ if (lkBuild) lkBuild.classList.remove('d-none');
+ if (lkIcon) lkIcon.classList.remove('d-none');
+ }
+ }
+ _renderEngineUi(eng);
+ // `dt.graph_engine` can be stale even after a build: it reflects the
+ // engine recorded on the domain at build-time, not necessarily the
+ // active global engine. Reconcile against /settings/graph-engine.
+ fetch('/settings/graph-engine', { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (data) {
+ var globalEng = data && data.graph_engine;
+ if (!globalEng || globalEng === cfg.graph_engine) return;
+ cfg.graph_engine = globalEng;
+ window.__TRIPLESTORE_CONFIG = cfg;
+ _renderEngineUi(globalEng);
+ })
+ .catch(function () { /* leave fallback in place */ });
var sub = document.getElementById('dtGraphStorageSubtitle');
var primaryRow = document.getElementById('dtGraphPrimaryRow');
if (sub) sub.classList.add('d-none');
if (primaryRow) primaryRow.classList.add('d-none');
var regRow = document.getElementById('dtRegistryArchiveRow');
if (regRow) regRow.classList.add('d-none');
- var lkDetails = document.getElementById('dtLakebaseDetails');
- if (lkDetails) lkDetails.classList.toggle('d-none', eng !== 'lakebase');
if (eng === 'lakebase') {
var lkDb = document.getElementById('dtLakebaseDatabase');
diff --git a/src/front/templates/partials/domain/_domain_validation.html b/src/front/templates/partials/domain/_domain_validation.html
index b22ce2de..1a4316d3 100644
--- a/src/front/templates/partials/domain/_domain_validation.html
+++ b/src/front/templates/partials/domain/_domain_validation.html
@@ -241,15 +241,17 @@ Cockpit
-
+
-
+
-
+
-
+
+
+
+ Cypher write at build time
+
+
+
-
+
-
+
-
+
-
+
-
+
+
+
+ Cypher write at build time
+
+
+
-
+
+
+
+