diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4368956..5108dd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,21 @@ on: pull_request: branches: [main] +# All jobs install from uv.lock (`uv sync --locked`) so CI resolves the exact +# versions contributors tested against. The previous recipe mixed +# `uv pip install` (latest resolutions) with `uv run`'s implicit lockfile +# sync, which could downgrade base deps underneath freshly-installed extras — +# a new anyio release made that skew fatal (typing_extensions ImportError at +# collection). If `--locked` fails, run `uv lock` locally and commit the +# refreshed lockfile. + jobs: test: name: Test (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: + # One version's failure shouldn't cancel the other's signal. + fail-fast: false matrix: python-version: ["3.11", "3.12"] @@ -23,11 +33,11 @@ jobs: - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} - - name: Install dependencies - run: uv venv && uv pip install -e ".[dev]" + - name: Install dependencies (locked) + run: uv sync --locked --extra dev --python ${{ matrix.python-version }} - name: Run tests - run: uv run pytest tests/ -v --tb=short + run: uv run --no-sync pytest tests/ -v --tb=short lint: name: Lint @@ -41,16 +51,18 @@ jobs: - name: Set up Python run: uv python install 3.12 - - name: Install dependencies - run: uv venv && uv pip install -e ".[dev]" ruff + - name: Install dependencies (locked) + run: uv sync --locked --extra dev --python 3.12 - name: Run ruff - run: uv run ruff check src/ tests/ + run: uv run --no-sync ruff check src/ tests/ - matrix: + full-suite: + # Full test suite — slow markers (domain x framework matrix, performance, + # generated-project venv suites) plus the functional local-file vault + # tests. Runs on every PR and on pushes to main. name: Full Suite + Domain x Framework Matrix runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 @@ -61,13 +73,15 @@ jobs: - name: Set up Python run: uv python install 3.12 - - name: Install dependencies - run: uv venv && uv pip install -e ".[dev]" + - name: Install dependencies (locked) + run: uv sync --locked --extra dev --extra connectors --python 3.12 - - name: Run full test suite with slow tests (matrix + performance) - run: uv run pytest tests/ -v --tb=short --slow + - name: Run full test suite (slow + functional) + run: uv run --no-sync pytest tests/ -v --tb=short --slow --functional smoke-test: + # Needs repository secrets (Neo4j + LLM API keys), which aren't available + # to pull requests from forks — stays main-push-only. name: E2E Smoke Test (${{ matrix.test.framework }}) runs-on: ubuntu-latest needs: test @@ -96,15 +110,15 @@ jobs: - name: Set up Python run: uv python install 3.12 - - name: Install dependencies - run: uv venv && uv pip install -e ".[all,dev]" + - name: Install dependencies (locked) + run: uv sync --locked --all-extras --python 3.12 - name: Run integration tests env: NEO4J_URI: ${{ secrets.NEO4J_URI }} NEO4J_USERNAME: ${{ secrets.NEO4J_USERNAME }} NEO4J_PASSWORD: ${{ secrets.NEO4J_PASSWORD }} - run: uv run pytest tests/test_integration.py --integration -v --tb=short + run: uv run --no-sync pytest tests/test_integration.py --integration -v --tb=short - name: Run smoke test env: @@ -115,7 +129,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} run: > - uv run python scripts/e2e_smoke_test.py + uv run --no-sync python scripts/e2e_smoke_test.py --domain ${{ matrix.test.domain }} --framework ${{ matrix.test.framework }} --quick diff --git a/CHANGELOG.md b/CHANGELOG.md index 3316d60..cd5e6af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,77 @@ # Changelog +## v0.14.0 — community PR hardening: NEO4J_DATABASE, NAMS cypher runtime, --ontology-file (unreleased) + +Integrates five community PRs merged since v0.13.1 (#52, #56, #58, #59, #60), closes the gaps found while reviewing them, and adds test coverage at every level: +105 fast-suite tests, +3 Neo4j integration tests, a new bolt combo in the slow generated-suite runner, and 2 new tests inside every generated project (fast suite now 1,454 passing; the full CI suite with the connectors extra runs 1,866 passing / 1,881 collected). The new integration coverage immediately exposed — and this release fixes — a long-standing schema-DDL splitter bug that had been silently dropping five indexes/constraints from every seeded database. + +### Features (community PRs) + +- **`NEO4J_DATABASE` support end-to-end** (#60, @henrardo). New `--neo4j-database` flag / `NEO4J_DATABASE` env var for the self-hosted backend, threaded through the CLI, wizard (new prompt on manual credential entry), Aura `.env` import (previously read and silently discarded), generated `.env`, the generated app's `Settings`, memory-layer `MemorySettings`, and the raw-driver session in `execute_cypher()`. Blank defers to the driver default (`neo4j`); set it for Aura instances provisioned via the Aura API/CLI, which commonly name the database after the instance id. +- **Memory-layer failures now surface in `/health`** (#60, @henrardo). `store_message()` failures are recorded into the same classified error state used at startup connect time; the bolt `/health` response gains `memory`, `memory_error`, and `memory_error_detail` fields, and the startup lifecycle checks the memory client instead of assuming success once the raw driver connects. Previously a wrong database name meant the app reported "ok" while every memory write failed with only a log line. +- **`execute_cypher()` works on NAMS** (#56, @benmyrgorod). New `_execute_nams_cypher()` routes through `client.query.cypher` with result-shape coercion (`_coerce_nams_records`), so domain agent tools and `POST /cypher` execute read queries on the hosted backend instead of dying on the never-connected bolt driver. `_require_neo4j()` now returns 503 when the NAMS client is missing, for every route. +- **`--ontology-file` wired up** (#58, @irene221b). The flag was documented (and tracked as #50) but had no code path. Scaffolds directly from a hand-written domain YAML — no LLM call — using the domain id declared in the file. +- **Custom domains resolve by id** (#52, @ecsricktorzynski, issue #30). `load_domain()` now searches `~/.create-context-graph/custom-domains/` (with a declared-`domain.id` fallback for renamed files), matching what `list_available_domains()` advertises. Bundled domains shadow same-id custom files. +- **`demo_scenarios` optional in practice** (#59, @irene221b). The generated Playwright spec indexed `demo_scenarios[0].prompts[0]` unconditionally, crashing scaffold generation for any domain that omits scenarios. Falls back to a generic prompt. + +### Bug Fixes (gaps found reviewing the PRs) + +- **`--ontology-file` scaffolds now include `data/ontology.yaml`.** The renderer only wrote the ontology copy for `--custom-domain` (YAML string) or bundled domains (copy by id); hand-written ontologies matched neither branch, silently producing a scaffold without the file the docs promise. The raw YAML is now carried into the scaffold verbatim, and the flag also works when the wizard collects the remaining settings. `--ontology-file` + `--custom-domain` is now an explicit error instead of a silent precedence pick. (`cli.py`) +- **`NEO4J_DATABASE` reaches scaffold-time ingest and the generated import script.** #60 threaded the database through the generated app but not through `ingest.py` (`_ingest_with_memory_client`, `_ingest_with_driver`, `reset_neo4j`) or the scaffolded `import_data.py` bolt path — so `--demo` / `--ingest` / `--reset-database` / `make import` targeted the *default* database while the app read from the configured one, producing an empty graph on non-default-database instances. All bolt sessions now honor the setting; `validate_connection()` gains an optional `database` parameter. (`ingest.py`, `neo4j_validator.py`, `templates/backend/connectors/import_data.py.j2`) +- **`.env.example` documents `NEO4J_DATABASE`.** #60 updated `dot_env.j2` only; the example file now carries the same commented block. (`templates/base/dot_env_example.j2`) +- **Playwright spec survives a scenario with an empty `prompts` list.** #59's guard handled a missing `demo_scenarios` but still crashed rendering when the first scenario had `prompts: []` (legal per the Pydantic model). (`templates/frontend/e2e/app.spec.ts.j2`) +- **Schema DDL splitter no longer eats statements or executes comment tails.** Every consumer of `generate_cypher_schema()` output split on `;` and skipped fragments starting with `//` — which (a) silently dropped the 5 real statements that sit behind comment headers (`person_name`, `document_title`, `document_domain`, `document_name_unique` and the `local_file_fulltext` index were never created by `make seed`/ingest), and (b) executed the tail of the "dimensions must match your embed model" comment as Cypher, raising `CypherSyntaxError` on every schema apply. New shared `split_cypher_statements()` strips comment lines before splitting; used by `ingest.py` (both bolt paths), the generated `generate_data.py`, and the integration suite. (`ontology.py`, `ingest.py`, `templates/backend/shared/generate_data.py.j2`) +- **Test suite is hermetic against `~/.create-context-graph/custom-domains/`.** With #52, `load_domain()` joins `list_available_domains()` in scanning the user-local directory — so a contributor's saved custom domains leaked into every domain-iterating test (the "football-intelligence" failures reported while developing #60). An autouse conftest fixture isolates the path; tests that need custom domains patch it explicitly. (`tests/conftest.py`) + +### NAMS domain ontology activation + +NAMS auto-binds every workspace to the generic `nams-default` ontology "until an explicit ontology is activated" — and pre-registers a server-side ontology for **every bundled domain** (a 1:1 catalog; the server's `healthcare` document is field-for-field our `healthcare.yaml`). Nothing ever activated one, so all data was stamped with (and extraction spoke) the default vocabulary. Now every NAMS touchpoint binds the workspace to the app's domain first: + +- **Generated `memory.py`**: `connect_memory()` runs `_ensure_nams_ontology()` after the client connects — already active → no-op; catalog match by domain id → activate its latest version; unknown domain → **create** the ontology from the scaffold's new `backend/app/ontology_document.json` and activate it (the custom-domain path). Best-effort and logged: memory still works on `nams-default` if the ontology API is unavailable. Covers app startup and `make seed` (which connects through the same path). +- **CLI ingest** (`run_nams_ingest`) and the **scaffolded `import_data.py`** run the same ensure sequence as stage 0, pinned by the parity contract test (`get_active → list → get → activate`, keyword-called so the recorder can compare shapes). +- **`build_nams_ontology_document()`** (`ontology.py`) produces the server's `OntologyDocument` shape — `{domain, entity_types, relationships}`, excluding app-side sections (`agent_tools`, `system_prompt`, `document_templates`, …) — used by both the renderer (writes `ontology_document.json` into every scaffold) and the CLI ingest. + +Verified against the production service: a fresh workspace on `nams-default` flips to `healthcare` on the generated app's first connect (reconnect is a no-op), and a custom `test-domain` scaffold **creates and activates** its ontology server-side with the full merged label set. Every stored entity is stamped with the active `ontologyVersionId`. Known limitation (server-side): the stored entity `type` remains {Person, Organization, Location, custom} regardless of the active ontology — activation governs the version stamp, validation mode, and extraction vocabulary, not the storage-type enum. + +### Live NAMS verification (and the bugs it caught) + +The full flow — scaffold → ingest → boot → API — was exercised against the production NAMS service (`memory.neo4jlabs.com`) with a real API key and `neo4j-agent-memory` 0.5.0 (the version fresh scaffolds install). That surfaced five breaks the mocked suites couldn't see, all fixed and now pinned by tests; 19/19 live API checks pass afterward: + +- **Conversation memory silently failed on every message.** The NAMS service only accepts messages addressed to conversation ids *it* minted at create time — client-chosen session ids 404 with "conversation not found," and `MemoryIntegration` (0.5.x) both posts the client id straight through and swallows the failure into an `{"error": ...}` return value. The generated `memory.py` now creates the conversation on first use per session and addresses the server-assigned id (`_resolve_nams_conversation()`, process-local cache — a restart starts a fresh conversation instead of erroring), and `store_message()` treats `{"error": ...}` returns as failures so they surface in `/health` rather than reading as success. +- **Every document/body ingest write failed** (`role must be user, assistant, or system` + the same conversation-404). All three NAMS ingest implementations (`run_nams_ingest`, the scaffolded `import_data.py`, and `make seed`'s `ingest_fixtures_nams`) now create their message channels via `create_conversation` and send `role="user"` with a `metadata.kind` marker (`"document"` / `"entity-body"`). Before: 0/25 documents ingested; after: clean. +- **`/api/documents` and `/api/schema/visualization` returned nothing.** The live service coerces unknown `entity_type` values (`OBJECT`, `EVENT`) to `custom`, so the server-side OBJECT filter matched nothing — and it rejects empty search queries (`query is required`), so the schema view's enumerate-everything search 400'd. Both adapters are now cypher-first (using the scaffold's own `_pole_type: OBJECT_` description marker for documents, and a type-count aggregation for the schema view), with the search-based flows kept as fallbacks. +- **Graph expand / entity connections were dead.** `long_term.get_entity(id)` doesn't exist in `neo4j-agent-memory` 0.5.x; `expand_node_nams` and `get_entity_detail_nams` now resolve id-addressed lookups and neighbor edges through the cypher API (which also surfaces the server-created `SAME_AS` resolution edges), keeping the old REST flow as a fallback. +- **NAMS reset never deleted anything.** `long_term.delete_entity` doesn't exist in 0.5.x (the old code swallowed the `AttributeError` and reported "0 entities removed"), the REST API has no delete endpoint, and the cypher API is read-only. `--reset-database` and the scaffold's `make reset` now say so honestly (with the current entity count) and point at the NAMS dashboard, instead of pretending. Docs updated to match. + +Verified unchanged live: `client.query.cypher` read queries (the PR #56 dispatch) work and write queries are rejected; the reasoning trace ingest (`start_trace`/`add_step`/`complete_trace`) succeeds; `add_relationship`/`add_fact`/`add_preference` exist in 0.5.0 but raise `NotSupportedError` against NAMS, so the `ccg-edges` encoding remains the correct design; `reasoning.list_traces` is NotSupported client-side, so `/api/traces` degrades to an empty list on NAMS. Upstream issues worth filing against `neo4j-agent-memory`: `MemoryIntegration` should create conversations (or the service should honor client ids), `add_message` shouldn't silently target nonexistent conversations, and `add_entity` throws a client-side validation error when the server responds with a dedup/merge result. + +### Testing + +- **`tests/test_generated_client_runtime.py`** (new, 31 tests) — executes the rendered `context_graph_client.py` and `memory.py` against doubles: NAMS dispatch + result coercion + tool-event collection + not-connected error; bolt session `database=` threading; `MemorySettings` database pass-through; `store_message()` error recording/clearing/`NotSupportedError` handling; the `_classify_memory_error` buckets `/health` reports; the NAMS conversation-id translation (created once per session, server id targeted, bolt untouched, create-failure fallback) and the swallowed-`{"error"}` failure path. +- **`test_routes_integration.py`** (+6) — mounts the generated FastAPI app: `POST /cypher` on NAMS dispatches through `execute_cypher` (and maps errors to 400), API routes 503 when the NAMS client is missing while `/health` reports degraded, bolt `/cypher` still injects the `$domain` parameter, and a live `store_message` failure flips `/health` to degraded with classified fields — then a successful write clears it Three more pin the cypher-first adapters: documents enumerated by description marker (search fallback for custom-typed entities), schema visualization aggregated via cypher. +- **`test_cli.py`** (+12) — `TestNeo4jDatabaseFlag` (flag→`.env`, blank default, Aura import, flag-beats-file precedence, dry-run display) and `TestOntologyFileFlag` (scaffold, `data/ontology.yaml` copy, static demo data, invalid YAML exit 1, missing file exit 2, `--custom-domain` conflict, auto-slug). +- **`test_wizard.py`** (+5) — `_parse_aura_env` four-tuple contract: database read/absent/quoted, missing URI/password aborts. +- **`test_generated_project.py`** (+17) — template pins for the database threading (config/client/memory/import script/`.env`/`.env.example`, NAMS `.env` exclusion), memory-error surfacing in `main.py`, the NAMS cypher branch, and scenario-fallback rendering (none / empty-prompts / real prompts). +- **`test_ontology.py`** (+9) — `split_cypher_statements` unit tests (semicolon-in-comment, comment-header recovery, commented-out DDL dropped, all-domains executable-statement sweep, proof the old pattern dropped `person_name`) and custom-domain isolation meta-tests. +- **`test_custom_domain.py`** (+3) — resolution precedence: bundled shadows same-id custom, corrupt custom YAML skipped during the id scan, underscore files ignored. +- **`test_bolt_ingest_parity.py`** (+1) — the scaffolded bolt import session must target `settings.neo4j_database`. +- **`test_integration.py`** (+3, `--integration`) — explicit-database ingest lands data (via `ProjectConfig.neo4j_database`), `validate_connection` accepts a database name and rejects an unknown one. `TestSchemaCreation` now applies DDL through the shared splitter and asserts the previously-skipped indexes exist. +- **`test_generated_tests.py`** — the slow generated-suite runner now also scaffolds one **bolt** project (5 combos), and asserts the new backend-specific health tests actually ran. The generated `test_routes.py` gains two tests per backend: NAMS degraded-client reporting, bolt live-write-failure surfacing. +- **`scripts/e2e_smoke_test.py`** — asserts the `/health` contract shape per backend on startup (bolt: `neo4j` + `memory` fields, logging a warning with the classified error when degraded; NAMS: `nams` field). + +### CI + +- **The full test suite now runs on every PR.** The `full-suite` job (formerly `matrix`, main-push-only) runs `pytest --slow --functional` on all pull requests and main pushes: the 176-combo domain × framework matrix, performance tests, generated-project venv suites, and the local-file vault functional tests (the `connectors` extra is installed, which both enables `--functional` and materializes ~165 connector-dependent tests that module-level `importorskip` guards silently excluded from dev-only CI — the job runs 1,866 tests). The secrets-dependent smoke-test job stays main-only — fork PRs can't access repository secrets. +- **CI installs are lockfile-driven.** Every job now uses `uv sync --locked --extra ...` + `uv run --no-sync`. The old recipe (`uv pip install -e ".[dev]"` followed by bare `uv run pytest`) let `uv run`'s implicit lockfile sync downgrade locked base dependencies underneath freshly-installed latest extras — a new `anyio` release (requiring `typing_extensions.sentinel`, newer than the locked pin) made the skew fatal, failing test collection with `ImportError: cannot import name 'sentinel' from 'typing_extensions'` on every branch. `--locked` also means a `pyproject.toml` dependency change without a matching `uv lock` fails fast with a clear message instead of resolving to something untested. Makefile test/lint targets use `uv run --extra dev ...` so local runs resolve from the lock the same way. +- **14 accidentally-tracked `.pyc` files untracked.** They were committed in the initial commit (before `.gitignore` applied) and have silently churned in diffs ever since; `.gitignore` already covers `__pycache__/` going forward. +- **Ruff's rule set and version are pinned.** The lint job previously installed unpinned latest ruff with no project config, so ruff 0.16's expanded default rule set broke the build with 294 findings for rules this codebase never adopted. `[tool.ruff.lint]` now pins `select = ["E4", "E7", "E9", "F"]` (the set the codebase is written against — expand it deliberately, not via upstream default drift), and `ruff>=0.16,<0.17` ships in the dev extra so CI and `make lint` run the same binary. Test-job matrix gains `fail-fast: false` so one Python version's failure no longer cancels the other's signal. + +### Docs + +- `reference/cli-options.md` — `--neo4j-database` and `--ontology-file` rows, `NEO4J_DATABASE` in the env-var table. +- `how-to/use-neo4j-aura.md` — caution block on Aura API/CLI-provisioned database names and how the failure presents in `/health`. +- `how-to/add-custom-domain.md` — `--ontology-file` semantics: domain id from the file, `data/ontology.yaml` copy, mutual exclusion with `--custom-domain`. +- `reference/generated-project-structure.md` — `.env` listing includes `NEO4J_DATABASE`. + ## v0.13.1 — v0.13.0 feedback report triage (unreleased) Addresses the May 20, 2026 v0.13.0 feedback report. The report mixed verified issues with claims that don't match the current codebase; each claim was verified before scoping work. This release closes every real issue, makes the generated `app.models` module load-bearing, and adds regression tests so the v0.12.0/v0.13.0 fixes can't silently come back. diff --git a/CLAUDE.md b/CLAUDE.md index 9145a4d..7460629 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Interactive CLI scaffolding tool that generates domain-specific context graph ap Given a domain (e.g., "healthcare", "wildlife-management") and an agent framework (e.g., PydanticAI, Claude Agent SDK), it generates a complete full-stack application: FastAPI backend, Next.js + Chakra UI v3 + NVL frontend, Neo4j schema, synthetic data, and a configured AI agent with domain-specific tools. -**Status:** v0.13.1. **v0.13.0 feedback-report triage release.** `create-context-graph --dry-run` no longer demands a NAMS API key (the credential gate is now scoped to non-dry-run flows in `cli.py:392`). Dead `template_id` parameter dropped from `list_documents_nams` (NAMS branch already raises 501). Generated Pydantic `app/models.py` now emits `Field(...)` for required fields instead of bare `= ...` Ellipsis literal. New `GET /schema/models` endpoint introspects `app.models` at runtime and returns the JSON Schema for each entity model — makes the previously-unused `models.py` load-bearing. Last `key={\`${e.name}-${i}\`}` site (DocumentBrowser mentioned-entities) replaced with `key={\`${selectedDoc.document.title}-${e.name}\`}`. New regression tests (`TestCompositeKeyRegressions` in `test_frontend_logic.py` plus `TestV0131ModelsPolish` and `TestV0131TemplateIdRemoval` in `test_generated_project.py`) plus 3 new Playwright tests pin the v0.12.0/v0.13.0 fixes. The v0.13.0 report's claims about connector code being removed from scaffolded projects, `pyproject.toml` bloat on NAMS, and missing domains (`media`, `insurance`, `supply-chain`) were verified-false against current code — see `CHANGELOG.md` "Not Changed" section. **Carry-forward from v0.13.0 (v0.12.0 feedback-report fixes):** Fixed the bolt async/sync mismatch in the scaffolded `import_data.py` (`_ingest_via_bolt()` is `async def` and both call sites wrap with `asyncio.run`); ChatInterface stale-closure bug fixed by moving `streamingEntities`/`streamingPreferences` to refs; `list_documents_nams` pushes the OBJECT filter server-side; `externalInput` useEffect depends on `loading`. Removed the `--framework maf` alias entirely (Click rejects with "Invalid value"). Restored 4 domains as YAML definitions (`legal`, `education`, `cybersecurity`, `government`); domain count is now 27. New Docusaurus page `docs/docs/explanation/ccg-edges.md` documents the relationship-encoding strategy. **Carry-forward from v0.12.0 (NAMS-native connector ingest release):** Default memory backend is the hosted Neo4j Agent Memory Service (NAMS, `MEMORY_API_KEY` env), with `--self-hosted` preserving the bolt-Neo4j path. **LiteLLM provider injection** for memory layer via `MEMORY_LLM` / `MEMORY_EMBEDDING` env vars (LiteLLM-style strings) routes through native adapters when available, LiteLLM otherwise. Default framework is **AWS Strands**. Wizard collapsed from 11 prompts to 6 with autocomplete domain picker and a single "Customize advanced settings" gate. NAMS ingest now uses a **hybrid write shape**: entities via `add_entity` with non-name properties markdown-serialized into the `description` field; **outbound relationships encoded into the source entity's `description` as a fenced ```ccg-edges``` YAML block** (sorted by `type` then `target`) since NAMS REST has no `add_relationship` yet — the frontend parses these out for the graph view, and a future migration will replay them as native edges; **documents dual-tracked** as `long_term.add_entity(type=OBJECT)` (queryable source of truth, matches bolt `:Document` shape) AND `short_term.add_message(role="document")` (extraction fuel); **entity bodies routed through `add_message`** when the connector declares a `BODY_FIELDS: dict[label, property]` map (e.g. `Comment.body` for Linear, `Message.content` for Claude AI/ChatGPT/Claude Code, `DecisionThread.content`/`Reply.content` for Google Workspace, `Document.description`/`Section.description` for local-file); decision traces via `reasoning.start_trace/add_step/complete_trace`. Preferences and facts still unsupported by NAMS REST (logged-skipped). Schema DDL skipped on NAMS (server-owned). The two ingest consumers (`src/create_context_graph/ingest.py` `run_nams_ingest()` and the scaffolded `templates/backend/connectors/import_data.py.j2`) share the same NAMS write sequence pinned by `tests/test_nams_ingest_parity.py` (~526 LOC contract test). Generated `import_data.py` is **idempotent**: per-connector watermarks in `.context-graph/watermarks.json` (re-runs fetch deltas only), failures appended to `.context-graph/deadletter.jsonl`, new `--dry-run` (fetch only) and `--retry` (drain deadletter) modes, surfaced via `make import` (fetch + ingest), `make import-dry-run`, `make import-retry` (the legacy `make import-and-seed` target is gone — `make import` is the single entrypoint). `/documents?template_id=...` returns HTTP 501 on NAMS (template filtering needs `MENTIONS` edges that NAMS doesn't have); un-filtered `/documents` works on both backends. Bolt ingest hardened with `_require_safe_cypher_identifier()` (relationship types and labels validated against `[A-Za-z_][A-Za-z0-9_]*` before string interpolation) and labeled `MATCH (a:SourceLabel)` / `MATCH (b:TargetLabel)` (no more label-less fallback that could mis-merge). `ingest_data()` library entry point now accepts either a `ProjectConfig` or the legacy `(neo4j_uri, neo4j_username, neo4j_password)` triple (v0.11.0 had broken legacy callers). Frontend routes (`/expand`, `/documents`, `/traces`, `/schema/visualization`, `/entities/{name}`, `/search`) dispatch through `memory_adapter.py` on NAMS. MCP profile coerced to `core` on NAMS (extended-profile tools rely on unsupported endpoints). `auto_preferences` forced `False` on NAMS. Generated `pyproject.toml` pins `neo4j-agent-memory[litellm,sentence-transformers]>=0.4.0,<0.6.0`; self-hosted scaffolds also pull `[extraction,fuzzy]`. 27 domains, 8 agent frameworks (7 working out of box — openai-agents requires OPENAI_API_KEY, google-adk requires GOOGLE_API_KEY with clear warnings), **streaming chat via Server-Sent Events** (token-by-token text in 6 frameworks + real-time tool call visualization with Timeline/Spinner/Collapsible + `entities_extracted` and `preferences_detected` SSE events displayed as badges), neo4j-agent-memory v0.1.0 MemoryIntegration for multi-turn conversations with automatic entity extraction and preference detection (local sentence-transformers embeddings by default — no OpenAI key required; auto-upgrades to OpenAI embeddings if OPENAI_API_KEY is set), MCP server generation for Claude Desktop (`--with-mcp` flag, `make mcp-server` target, dual-interface architecture), configurable session strategies (`per_conversation`/`per_day`/`persistent` via `SESSION_STRATEGY` env var), interactive NVL graph visualization (schema view, double-click expand, drag/zoom, property panel, agent-driven graph updates — now updated incrementally during streaming, node hover tooltips, "Ask about this" button), LLM-generated demo data (80-90 entities, 25+ documents, 8-12 decision traces per domain) with post-generation value clamping for 28 property types, markdown rendering in chat and document browser with ReactMarkdown, document browser with pagination, entity detail panel, decision trace viewer, 13 SaaS connectors (including Linear for real project data import, Google Workspace for decision trace extraction from comment threads, Claude Code for local session history import with decision/preference extraction, Claude AI + ChatGPT for importing conversation exports from AI chat platforms, and local-file for deterministic ingestion of local Markdown / PDF / HTML / AsciiDoc / Word documents into Document → Section hierarchies), custom domain generation, Neo4j Aura .env import + neo4j-local support, Docusaurus documentation site (24 pages including quick-start, domain catalog, framework comparison, Neo4j Aura/Local guides, Docker guide, architecture diagram, "Why Context Graphs?" explainer, Google Workspace tutorial, decision trace explainer, GWS schema reference, chat history import tutorial, chat import schema reference, and `ccg-edges` encoding explainer), graceful Neo4j degradation with /health endpoint (retry with backoff on initial load) and 503 guards on all endpoints, Cypher injection prevention, enum identifier sanitization, configurable CORS/model/timeouts, --dry-run/--verbose/--reset-database/--demo CLI flags, CLI auto-slug generation (PROJECT_NAME optional in non-interactive mode), CLI warnings for framework-specific API key requirements (openai-agents, google-adk), constants module, WCAG accessibility improvements, chat timeout/cancel with AbortController, mobile-responsive layout, .dockerignore for Docker builds, `make test-connection` target, framework-specific README sections, troubleshooting guide, thread-safe async bridging for sync frameworks (CrewAI/Strands), bounded agentic loops (max 15 iterations), domain-specific static name pools (1000+ names across 118 entity labels — all domain YAML labels covered) with domain-aware base entities, tool-use emphasis in all agent system prompts, domain-scoped chat history localStorage keys, SSR hydration fix, retry button on chat errors, PydanticAI tool serialization fix (JSON string return types), Google ADK API key support (--google-api-key flag) with AttributeError guard for SDK cleanup, Strands robust text extraction, CrewAI explicit Anthropic LLM config with `crewai[anthropic]` dependency, domain-scoped MERGE keys (`{name, domain}`) for cross-domain isolation when sharing a Neo4j instance, improved static data quality (26 domain-specific industry pools, POLE-type-aware entity descriptions with 5 label categories + 7 label-specific overrides, entity-derived document titles, realistic decision trace observations, 20+ domain-specific property pools, float value clamping, taxonomy class correction), agent thinking text collapsible filter with continuation pattern support, Cypher query validation tests across all domains, fixture cross-validation tests (schema alignment + data quality), proper Document/DecisionTrace node ingestion via --ingest, Chakra UI Pro-inspired chat input redesign, list/get-by-id agent tools for all 27 domains (7-8+ tools per domain), ON CREATE/MATCH SET for constraint-safe seeding, hardened Linear connector (named constants, structured logging, URLError/JSONDecodeError/429 handling with retry, pagination safety limits, null-safe field access, team key validation in authenticate(), incremental sync via updated_after, decision traces in generated template), optional credential prompts in interactive wizard, Google Workspace connector with decision trace extraction from 6 Google APIs (Drive Files, Comments, Revisions, Activity, Calendar, Gmail) with 10 decision-focused agent tools and cross-connector Linear linking, scaffolded Claude Code connector with full entity extraction (9 entity types, 14 relationship types, secret redaction, decision/preference extraction, language detection), connector-specific demo scenarios, 1,335 passing tests (1,566 collected including slow/integration/functional). +**Status:** v0.14.0. **Community PR hardening release** — integrates PRs #52/#56/#58/#59/#60, closes the gaps found reviewing them, and fixes five breaks found by live-testing the full NAMS flow against the production service with neo4j-agent-memory 0.5.0: (1) conversation memory was silently failing on every message — the NAMS service only accepts messages addressed to conversation ids IT minted, so generated `memory.py` now creates conversations per session and targets the server id (`_resolve_nams_conversation()`), and `store_message()` treats MemoryIntegration's swallowed `{"error": ...}` returns as failures; (2) every document/body ingest write failed (`role="document"` rejected — only user/assistant/system allowed — plus the conversation-404), fixed across all three NAMS ingest implementations (`run_nams_ingest`, scaffolded `import_data.py`, `make seed`'s `ingest_fixtures_nams`) with `role="user"` + `metadata.kind` markers and server-created channels; (3) `/api/documents` and `/api/schema/visualization` returned nothing (the live service coerces `OBJECT`/`EVENT` entity types to `custom` and rejects empty search queries) — both adapters are now cypher-first via the `_pole_type: OBJECT_` description marker / type-count aggregation with search fallbacks; (4) graph expand + entity connections were dead (`long_term.get_entity(id)` doesn't exist in 0.5.x) — `expand_node_nams`/`get_entity_detail_nams` resolve through the cypher API; (5) NAMS reset never deleted anything (`delete_entity` doesn't exist upstream; the old loop swallowed the AttributeError and printed "0 removed") — `--reset-database` and `make reset` now honestly report that no delete API exists and point at the NAMS dashboard. **NAMS domain ontology activation (new)**: NAMS pre-registers a server-side ontology for every bundled domain but auto-binds workspaces to `nams-default` until one is activated — generated apps now bind on `connect_memory()` (activate catalog match by domain id, or create-from-`backend/app/ontology_document.json` + activate for custom domains, best-effort via `_ensure_nams_ontology()`), the CLI ingest and scaffolded `import_data.py` run the same stage-0 sequence (parity-pinned), and `build_nams_ontology_document()` in `ontology.py` produces the server document shape ({domain, entity_types, relationships}) used by the renderer and ingest; verified live — workspace flips nams-default→healthcare on first connect, custom test-domain creates+activates server-side; stored entity `type` still coerces to {Person,Organization,Location,custom} server-side regardless (activation governs version stamp + extraction vocabulary). Verified live: 19/19 API checks, clean document ingest, working chat-memory writes, cypher reads (writes rejected server-side), trace ingest OK (`list_traces` NotSupported → `/traces` degrades to empty), and `add_relationship` still NotSupported so ccg-edges remains the design. New `--neo4j-database` / `NEO4J_DATABASE` for the self-hosted backend, threaded end-to-end: CLI flag, wizard prompt, Aura `.env` import (explicit flag wins), generated `.env` + `.env.example`, `Settings.neo4j_database`, `MemorySettings` (key omitted when blank so the SDK default `neo4j` applies), `execute_cypher()` sessions, scaffold-time `ingest.py` (`_ingest_with_memory_client`/`_ingest_with_driver`/`reset_neo4j`), the scaffolded `import_data.py` bolt session, and `validate_connection(database=)`. Live memory-write failures now surface: `store_message()` records into the classified error state and the bolt `/health` gains `memory`/`memory_error`/`memory_error_detail` (startup lifecycle checks `get_client()` instead of assuming success). `execute_cypher()` dispatches to NAMS `client.query.cypher` (with `_coerce_nams_records()` shape coercion) so agent tools and `POST /cypher` work on the hosted backend; `_require_neo4j()` 503s when the NAMS client is missing. `--ontology-file` is implemented (was documented-only, issue #50): scaffolds from a hand-written YAML, copies it to `data/ontology.yaml`, mutually exclusive with `--custom-domain`, works through the wizard path too. `load_domain()` resolves custom domains from `~/.create-context-graph/custom-domains/` with a declared-`domain.id` fallback (issue #30); bundled domains shadow same-id customs; the test suite is hermetic against that directory via an autouse conftest fixture. Playwright spec template survives missing `demo_scenarios` AND a scenario with empty `prompts`. **Schema-DDL splitter bug fixed**: the old `split(";")` + skip-`//` pattern silently dropped 5 real statements behind comment headers (`person_name`, `document_title`, `document_domain`, `document_name_unique`, `local_file_fulltext` were never created by seeding) and executed a comment tail as Cypher; new shared `split_cypher_statements()` in `ontology.py` is used by `ingest.py`, the generated `generate_data.py`, and `test_integration.py`. New test surfaces: `tests/test_generated_client_runtime.py` (26 tests executing the rendered client/memory modules against doubles), +6 app-level route tests, +12 CLI tests, +5 wizard tests, +17 template pins, +9 ontology tests, +3 integration tests, generated `test_routes.py` gains 2 backend-specific `/health` tests, `test_generated_tests.py` runs one bolt scaffold, and `e2e_smoke_test.py` asserts the `/health` contract shape. **Carry-forward from v0.13.1 (feedback-report triage):** `create-context-graph --dry-run` no longer demands a NAMS API key (the credential gate is now scoped to non-dry-run flows in `cli.py:392`). Dead `template_id` parameter dropped from `list_documents_nams` (NAMS branch already raises 501). Generated Pydantic `app/models.py` now emits `Field(...)` for required fields instead of bare `= ...` Ellipsis literal. New `GET /schema/models` endpoint introspects `app.models` at runtime and returns the JSON Schema for each entity model — makes the previously-unused `models.py` load-bearing. Last `key={\`${e.name}-${i}\`}` site (DocumentBrowser mentioned-entities) replaced with `key={\`${selectedDoc.document.title}-${e.name}\`}`. New regression tests (`TestCompositeKeyRegressions` in `test_frontend_logic.py` plus `TestV0131ModelsPolish` and `TestV0131TemplateIdRemoval` in `test_generated_project.py`) plus 3 new Playwright tests pin the v0.12.0/v0.13.0 fixes. The v0.13.0 report's claims about connector code being removed from scaffolded projects, `pyproject.toml` bloat on NAMS, and missing domains (`media`, `insurance`, `supply-chain`) were verified-false against current code — see `CHANGELOG.md` "Not Changed" section. **Carry-forward from v0.13.0 (v0.12.0 feedback-report fixes):** Fixed the bolt async/sync mismatch in the scaffolded `import_data.py` (`_ingest_via_bolt()` is `async def` and both call sites wrap with `asyncio.run`); ChatInterface stale-closure bug fixed by moving `streamingEntities`/`streamingPreferences` to refs; `list_documents_nams` pushes the OBJECT filter server-side; `externalInput` useEffect depends on `loading`. Removed the `--framework maf` alias entirely (Click rejects with "Invalid value"). Restored 4 domains as YAML definitions (`legal`, `education`, `cybersecurity`, `government`); domain count is now 27. New Docusaurus page `docs/docs/explanation/ccg-edges.md` documents the relationship-encoding strategy. **Carry-forward from v0.12.0 (NAMS-native connector ingest release):** Default memory backend is the hosted Neo4j Agent Memory Service (NAMS, `MEMORY_API_KEY` env), with `--self-hosted` preserving the bolt-Neo4j path. **LiteLLM provider injection** for memory layer via `MEMORY_LLM` / `MEMORY_EMBEDDING` env vars (LiteLLM-style strings) routes through native adapters when available, LiteLLM otherwise. Default framework is **AWS Strands**. Wizard collapsed from 11 prompts to 6 with autocomplete domain picker and a single "Customize advanced settings" gate. NAMS ingest now uses a **hybrid write shape**: entities via `add_entity` with non-name properties markdown-serialized into the `description` field; **outbound relationships encoded into the source entity's `description` as a fenced ```ccg-edges``` YAML block** (sorted by `type` then `target`) since NAMS REST has no `add_relationship` yet — the frontend parses these out for the graph view, and a future migration will replay them as native edges; **documents dual-tracked** as `long_term.add_entity(type=OBJECT)` (queryable source of truth, matches bolt `:Document` shape) AND `short_term.add_message(role="document")` (extraction fuel); **entity bodies routed through `add_message`** when the connector declares a `BODY_FIELDS: dict[label, property]` map (e.g. `Comment.body` for Linear, `Message.content` for Claude AI/ChatGPT/Claude Code, `DecisionThread.content`/`Reply.content` for Google Workspace, `Document.description`/`Section.description` for local-file); decision traces via `reasoning.start_trace/add_step/complete_trace`. Preferences and facts still unsupported by NAMS REST (logged-skipped). Schema DDL skipped on NAMS (server-owned). The two ingest consumers (`src/create_context_graph/ingest.py` `run_nams_ingest()` and the scaffolded `templates/backend/connectors/import_data.py.j2`) share the same NAMS write sequence pinned by `tests/test_nams_ingest_parity.py` (~526 LOC contract test). Generated `import_data.py` is **idempotent**: per-connector watermarks in `.context-graph/watermarks.json` (re-runs fetch deltas only), failures appended to `.context-graph/deadletter.jsonl`, new `--dry-run` (fetch only) and `--retry` (drain deadletter) modes, surfaced via `make import` (fetch + ingest), `make import-dry-run`, `make import-retry` (the legacy `make import-and-seed` target is gone — `make import` is the single entrypoint). `/documents?template_id=...` returns HTTP 501 on NAMS (template filtering needs `MENTIONS` edges that NAMS doesn't have); un-filtered `/documents` works on both backends. Bolt ingest hardened with `_require_safe_cypher_identifier()` (relationship types and labels validated against `[A-Za-z_][A-Za-z0-9_]*` before string interpolation) and labeled `MATCH (a:SourceLabel)` / `MATCH (b:TargetLabel)` (no more label-less fallback that could mis-merge). `ingest_data()` library entry point now accepts either a `ProjectConfig` or the legacy `(neo4j_uri, neo4j_username, neo4j_password)` triple (v0.11.0 had broken legacy callers). Frontend routes (`/expand`, `/documents`, `/traces`, `/schema/visualization`, `/entities/{name}`, `/search`) dispatch through `memory_adapter.py` on NAMS. MCP profile coerced to `core` on NAMS (extended-profile tools rely on unsupported endpoints). `auto_preferences` forced `False` on NAMS. Generated `pyproject.toml` pins `neo4j-agent-memory[litellm,sentence-transformers]>=0.4.0,<0.6.0`; self-hosted scaffolds also pull `[extraction,fuzzy]`. 27 domains, 8 agent frameworks (7 working out of box — openai-agents requires OPENAI_API_KEY, google-adk requires GOOGLE_API_KEY with clear warnings), **streaming chat via Server-Sent Events** (token-by-token text in 6 frameworks + real-time tool call visualization with Timeline/Spinner/Collapsible + `entities_extracted` and `preferences_detected` SSE events displayed as badges), neo4j-agent-memory v0.1.0 MemoryIntegration for multi-turn conversations with automatic entity extraction and preference detection (local sentence-transformers embeddings by default — no OpenAI key required; auto-upgrades to OpenAI embeddings if OPENAI_API_KEY is set), MCP server generation for Claude Desktop (`--with-mcp` flag, `make mcp-server` target, dual-interface architecture), configurable session strategies (`per_conversation`/`per_day`/`persistent` via `SESSION_STRATEGY` env var), interactive NVL graph visualization (schema view, double-click expand, drag/zoom, property panel, agent-driven graph updates — now updated incrementally during streaming, node hover tooltips, "Ask about this" button), LLM-generated demo data (80-90 entities, 25+ documents, 8-12 decision traces per domain) with post-generation value clamping for 28 property types, markdown rendering in chat and document browser with ReactMarkdown, document browser with pagination, entity detail panel, decision trace viewer, 13 SaaS connectors (including Linear for real project data import, Google Workspace for decision trace extraction from comment threads, Claude Code for local session history import with decision/preference extraction, Claude AI + ChatGPT for importing conversation exports from AI chat platforms, and local-file for deterministic ingestion of local Markdown / PDF / HTML / AsciiDoc / Word documents into Document → Section hierarchies), custom domain generation, Neo4j Aura .env import + neo4j-local support, Docusaurus documentation site (24 pages including quick-start, domain catalog, framework comparison, Neo4j Aura/Local guides, Docker guide, architecture diagram, "Why Context Graphs?" explainer, Google Workspace tutorial, decision trace explainer, GWS schema reference, chat history import tutorial, chat import schema reference, and `ccg-edges` encoding explainer), graceful Neo4j degradation with /health endpoint (retry with backoff on initial load) and 503 guards on all endpoints, Cypher injection prevention, enum identifier sanitization, configurable CORS/model/timeouts, --dry-run/--verbose/--reset-database/--demo CLI flags, CLI auto-slug generation (PROJECT_NAME optional in non-interactive mode), CLI warnings for framework-specific API key requirements (openai-agents, google-adk), constants module, WCAG accessibility improvements, chat timeout/cancel with AbortController, mobile-responsive layout, .dockerignore for Docker builds, `make test-connection` target, framework-specific README sections, troubleshooting guide, thread-safe async bridging for sync frameworks (CrewAI/Strands), bounded agentic loops (max 15 iterations), domain-specific static name pools (1000+ names across 118 entity labels — all domain YAML labels covered) with domain-aware base entities, tool-use emphasis in all agent system prompts, domain-scoped chat history localStorage keys, SSR hydration fix, retry button on chat errors, PydanticAI tool serialization fix (JSON string return types), Google ADK API key support (--google-api-key flag) with AttributeError guard for SDK cleanup, Strands robust text extraction, CrewAI explicit Anthropic LLM config with `crewai[anthropic]` dependency, domain-scoped MERGE keys (`{name, domain}`) for cross-domain isolation when sharing a Neo4j instance, improved static data quality (26 domain-specific industry pools, POLE-type-aware entity descriptions with 5 label categories + 7 label-specific overrides, entity-derived document titles, realistic decision trace observations, 20+ domain-specific property pools, float value clamping, taxonomy class correction), agent thinking text collapsible filter with continuation pattern support, Cypher query validation tests across all domains, fixture cross-validation tests (schema alignment + data quality), proper Document/DecisionTrace node ingestion via --ingest, Chakra UI Pro-inspired chat input redesign, list/get-by-id agent tools for all 27 domains (7-8+ tools per domain), ON CREATE/MATCH SET for constraint-safe seeding, hardened Linear connector (named constants, structured logging, URLError/JSONDecodeError/429 handling with retry, pagination safety limits, null-safe field access, team key validation in authenticate(), incremental sync via updated_after, decision traces in generated template), optional credential prompts in interactive wizard, Google Workspace connector with decision trace extraction from 6 Google APIs (Drive Files, Comments, Revisions, Activity, Calendar, Gmail) with 10 decision-focused agent tools and cross-connector Linear linking, scaffolded Claude Code connector with full entity extraction (9 entity types, 14 relationship types, secret redaction, decision/preference extraction, language detection), connector-specific demo scenarios, 1,454 passing tests in the fast suite; the full CI suite (dev+connectors extras, `--slow --functional`) runs 1,866 passing / 1,881 collected. ## Quick Reference @@ -149,6 +149,7 @@ my-app/ │ ├── main.py, config.py, memory.py, routes.py, models.py │ ├── agent.py # Framework-specific (8 frameworks available) │ ├── context_graph_client.py, gds_client.py, vector_client.py +│ ├── ontology_document.json # NAMS ontology doc (create-on-connect for custom domains) │ ├── connectors/ # Only if SaaS connectors selected │ │ ├── __init__.py │ │ └── {service}_connector.py # One per selected service @@ -176,23 +177,25 @@ my-app/ ### Unit Tests ```bash -pytest tests/ -v # All 1,335 tests (1,566 collected with slow/integration) +pytest tests/ -v # Fast suite: 1,454 passing (full CI suite with connectors extra: 1,866 passing / 1,881 collected) pytest tests/test_config.py # Config model + framework alias + google api key + crewai anthropic extra tests (26) pytest tests/test_ontology.py # Ontology loading + all 27 domains validate + enum sanitization + color collision checks + Cypher query validation pytest tests/test_renderer.py # Template rendering + all 8 frameworks + v0.3.0 features (64) pytest tests/test_generator.py # Data generation pipeline (14) -pytest tests/test_cli.py # CLI integration + 8 domain/framework combos + neo4j types + validation + auto-slug + Linear + Google Workspace + Claude Code connectors (54) +pytest tests/test_cli.py # CLI integration + 8 domain/framework combos + neo4j types + validation + auto-slug + --neo4j-database + --ontology-file + Linear + Google Workspace + Claude Code connectors (84) pytest tests/test_custom_domain.py # Custom domain generation with mocked LLM (17) pytest tests/test_connectors.py # SaaS connectors with mocked APIs (125, includes 58 Linear + 28 Google Workspace + 38 Claude Code tests) pytest tests/test_chat_import.py # Chat history import: Claude AI + ChatGPT parsers, connectors, CLI flags (78) -pytest tests/test_generated_project.py # Deep validation: Python/TS/Cypher syntax, memory, neo4j types, streaming, QA fixes, async bridging, thread safety, tool prompts, embeddings config (192) +pytest tests/test_generated_project.py # Deep validation: Python/TS/Cypher syntax, memory, neo4j types, streaming, QA fixes, async bridging, thread safety, tool prompts, embeddings config, NEO4J_DATABASE + NAMS cypher + scenario-fallback pins (269) pytest tests/test_fixtures.py # Cross-validation: schema alignment, agent tool property refs, data quality ranges (88) pytest tests/test_security.py # Cypher injection prevention: parameterization across 27 domains, generated code static analysis, run_cypher tool safety pytest tests/test_doc_snippets.py # Documentation validation: YAML examples parse, CLI flags exist, Cypher snippets valid, Make targets exist (8) pytest tests/test_frontend_logic.py # Frontend logic: SSE event parsing, thinking/response split, backend/frontend event type contract (25) +pytest tests/test_routes_integration.py # Mounts the generated FastAPI app: NAMS/bolt route dispatch, /cypher on NAMS, 503 guards, /health memory surfacing (17) +pytest tests/test_generated_client_runtime.py # Executes rendered context_graph_client.py + memory.py against doubles: NAMS dispatch/coercion, database threading, store_message error state (26) pytest tests/test_performance.py # Timed generation tests (slow, 27 domains) pytest tests/test_generated_tests.py # Scaffold + install + run generated project test suite for 4 frameworks (slow, 5) -pytest tests/test_integration.py --integration # Neo4j integration: schema DDL, fixture ingestion, agent tool queries, domain scoping (7) +pytest tests/test_integration.py --integration # Neo4j integration: schema DDL, fixture ingestion, agent tool queries, domain scoping, explicit-database threading (10) ``` Unit tests do NOT require Neo4j or any API keys. All tests use `tmp_path` fixtures for output. Integration tests require `--integration` flag and a running Neo4j instance (`NEO4J_URI`, `NEO4J_USERNAME`, `NEO4J_PASSWORD`). @@ -216,8 +219,8 @@ Required env vars: `NEO4J_URI`, `NEO4J_USERNAME`, `NEO4J_PASSWORD`, plus `ANTHRO | Target | Description | |--------|-------------| -| `make test` | Fast unit tests (955 tests, no external deps) | -| `make test-slow` | Full suite including matrix + perf + generated project tests (1,165 tests) | +| `make test` | Fast unit tests (1,454 passing, no external deps) | +| `make test-slow` | Full suite including matrix + perf + generated project + connector/functional tests (1,866 passing with connectors extra) | | `make test-matrix` | Domain × framework matrix only (176 combos) | | `make test-coverage` | Tests with HTML coverage report | | `make test-functional` | Functional test ingesting `tests/fixtures/local_file_vault/` end-to-end through the local-file connector (see `tests/fixtures/local_file_vault_TESTING.md` for the edge-case checklist) | @@ -298,14 +301,16 @@ GitHub Actions (`.github/workflows/ci.yml`) runs on push to `main` and all PRs: | Job | Trigger | Description | |-----|---------|-------------| -| **test** | All pushes + PRs | Unit tests on Python 3.11 and 3.12 (955 tests including security, doc snippets, frontend logic) | -| **lint** | All pushes + PRs | Ruff linter on `src/` and `tests/` | -| **matrix** | Push to `main` only | Full suite + 176 domain × framework matrix + 22 perf tests + generated project tests (1,165 tests) | -| **smoke-test** | Push to `main` only | Neo4j integration tests + E2E: scaffold → install → start → chat for all 8 frameworks | +| **test** | All pushes + PRs | Fast unit tests on Python 3.11 and 3.12 (`fail-fast: false` so one version's failure doesn't cancel the other) | +| **lint** | All pushes + PRs | Ruff on `src/` and `tests/` (rule set pinned in `[tool.ruff.lint]`, ruff version pinned in the dev extra) | +| **full-suite** | All pushes + PRs | Full test suite on 3.12: `--slow --functional` — 176 domain × framework matrix, perf tests, generated-project venv suites, local-file vault functional tests | +| **smoke-test** | Push to `main` only | Neo4j integration tests + E2E: scaffold → install → start → chat for all 8 frameworks (needs repository secrets, so it can't run on fork PRs) | + +**Every job installs from `uv.lock` via `uv sync --locked --extra ...` and runs commands with `uv run --no-sync`.** Never mix `uv pip install` with a bare `uv run` in CI: `uv run` does an implicit lockfile sync that downgrades locked base deps underneath freshly-installed latest extras (this skew broke CI when a new anyio required a newer typing_extensions than the lock pinned). If `--locked` fails, run `uv lock` locally and commit the refreshed lockfile — dependency changes in `pyproject.toml` must ship with a matching `uv.lock`. The Makefile test/lint targets use `uv run --extra dev ...` so local runs resolve from the lock the same way. The smoke-test job is gated behind `vars.SMOKE_TESTS_ENABLED == 'true'` (repository variable) and requires these repository secrets: `NEO4J_URI`, `NEO4J_USERNAME`, `NEO4J_PASSWORD`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`. It runs `test_integration.py --integration` before the E2E smoke tests. Uses `fail-fast: false` so one framework failure doesn't block others, and depends on the `test` job passing first. -Separate publish workflows (`publish-pypi.yml`, `publish-npm.yml`) trigger on version tags (`v*`). +A separate release workflow (`release.yml`) triggers on version tags (`v*`). ## What's Not Yet Implemented diff --git a/Makefile b/Makefile index e7a2bee..3dfdc16 100644 --- a/Makefile +++ b/Makefile @@ -14,20 +14,20 @@ install-all: ## Install all optional dependencies (dev + generate + connectors) ## Testing -test: ## Run fast tests (602 tests, no Neo4j or API keys required) - uv run pytest tests/ -v --tb=short +test: ## Run fast tests (1,454 passing, no Neo4j or API keys required) + uv run --extra dev pytest tests/ -v --tb=short test-slow: ## Run full suite including slow + functional vault tests (~2.7s extra) - uv run --extra connectors pytest tests/ -v --tb=short --slow --functional + uv run --extra dev --extra connectors pytest tests/ -v --tb=short --slow --functional test-matrix: ## Run domain x framework matrix only (176 combos) - uv run pytest tests/test_matrix.py -v --tb=short --slow + uv run --extra dev pytest tests/test_matrix.py -v --tb=short --slow test-coverage: ## Run tests with coverage report - uv run pytest tests/ -v --cov=create_context_graph --cov-report=html + uv run --extra dev pytest tests/ -v --cov=create_context_graph --cov-report=html test-functional: ## Run optional functional tests (ingest the local-file vault fixture) - uv run --extra connectors pytest tests/test_local_file_vault.py --functional -v --tb=short + uv run --extra dev --extra connectors pytest tests/test_local_file_vault.py --functional -v --tb=short smoke-test: ## E2E smoke test: scaffold, start, and chat for 3 key frameworks (requires Neo4j + API keys) @echo "Running smoke tests for pydanticai, google-adk, and strands..." @@ -38,10 +38,10 @@ smoke-test: ## E2E smoke test: scaffold, start, and chat for 3 key frameworks ( ## Linting lint: ## Run ruff linter - uv run ruff check src/ tests/ + uv run --extra dev ruff check src/ tests/ lint-fix: ## Auto-fix lint issues - uv run ruff check src/ tests/ --fix + uv run --extra dev ruff check src/ tests/ --fix ## Build & Publish diff --git a/docs/docs/explanation/memory-backends.md b/docs/docs/explanation/memory-backends.md index 3a3ac2b..cdbec2f 100644 --- a/docs/docs/explanation/memory-backends.md +++ b/docs/docs/explanation/memory-backends.md @@ -29,7 +29,7 @@ But the two backends have **different operational profiles**, and each makes sen | **Arbitrary Cypher reads** | Yes (`client.query.cypher`, read-only) | Yes | | **Arbitrary Cypher writes** | No (REST enforces read-only) | Yes | | **GDS algorithms** | No (501 Not Implemented) | Yes | -| **`make reset`** | Slow (per-entity REST delete) | Fast (`MATCH (n) DETACH DELETE n`) | +| **`make reset`** | Not available (no delete API in NAMS REST / neo4j-agent-memory 0.5.x — use the NAMS dashboard) | Fast (`MATCH (n) DETACH DELETE n`) | | **Data residency** | Hosted by Neo4j Labs | Wherever your Neo4j runs | | **Offline development** | No (needs network) | Yes (with Docker / neo4j-local) | diff --git a/docs/docs/how-to/add-custom-domain.md b/docs/docs/how-to/add-custom-domain.md index d78f5d0..b9441f5 100644 --- a/docs/docs/how-to/add-custom-domain.md +++ b/docs/docs/how-to/add-custom-domain.md @@ -44,6 +44,12 @@ Write a domain YAML file from scratch and point the CLI at it: create-context-graph my-app --ontology-file ./my-domain.yaml --framework langgraph ``` +The scaffold uses the domain id declared inside the YAML (overriding any +`--domain` value), and the file is copied into the project as +`data/ontology.yaml` alongside `_base.yaml`, so the generated project stays +self-contained. `--ontology-file` and `--custom-domain` are mutually +exclusive — pass one or the other. + Your YAML must follow the domain ontology schema. At minimum, include: ```yaml diff --git a/docs/docs/how-to/use-nams.md b/docs/docs/how-to/use-nams.md index bf570b6..be94569 100644 --- a/docs/docs/how-to/use-nams.md +++ b/docs/docs/how-to/use-nams.md @@ -130,15 +130,44 @@ cd backend uv pip install 'neo4j-agent-memory[litellm,sentence-transformers,extraction,fuzzy]>=0.4.0,<0.6.0' ``` -## Resetting NAMS state +## Domain ontology activation + +NAMS binds every workspace to a generic `nams-default` ontology until an +explicit one is activated — and it pre-registers a server-side ontology for +every bundled create-context-graph domain. As of v0.14.0, generated apps +handle this automatically: + +- **On startup** (`connect_memory()`), the app checks the workspace's active + ontology. If it doesn't match the app's domain, it activates the matching + catalog ontology (e.g. `healthcare`), so stored entities are stamped with + the domain's ontology version and server-side extraction uses the domain + vocabulary. +- **Custom domains** (`--custom-domain` / `--ontology-file`) aren't in the + server catalog, so the app **creates** the ontology from the scaffold's + `backend/app/ontology_document.json` (written at generation time from your + domain YAML) and activates it. +- The same binding runs at the start of `make import` and CLI-side + `--ingest`, so imported data is domain-stamped too. + +This is best-effort: if the ontology API is unavailable, the app logs a +warning and continues on `nams-default` — memory still works, just without +domain-shaped extraction. Note that the *stored* entity `type` currently +remains one of Person/Organization/Location/custom regardless of the active +ontology; activation governs the ontology-version stamp, validation mode, +and extraction vocabulary. -`make reset` on a NAMS project enumerates all entities via REST and deletes them one by one. Slow but correct: +## Resetting NAMS state -```bash -make reset -``` +Resetting from the CLI is **not currently possible**: neither the NAMS REST +API nor `neo4j-agent-memory` (through 0.5.x) exposes an entity delete +endpoint, and the NAMS cypher API is read-only. `make reset` and +`--reset-database` on a NAMS project print an explanation (with the current +entity count) instead of pretending to delete. -For fast resets, use a self-hosted scaffold — `MATCH (n) DETACH DELETE n` runs in milliseconds. +Manage stored data from the NAMS dashboard at +[memory.neo4jlabs.com](https://memory.neo4jlabs.com), or use a self-hosted +scaffold for full control — `MATCH (n) DETACH DELETE n` runs in milliseconds +on bolt. ## Troubleshooting diff --git a/docs/docs/how-to/use-neo4j-aura.md b/docs/docs/how-to/use-neo4j-aura.md index 21668b9..32be357 100644 --- a/docs/docs/how-to/use-neo4j-aura.md +++ b/docs/docs/how-to/use-neo4j-aura.md @@ -44,10 +44,22 @@ uvx create-context-graph my-app \ ``` This will: -- Parse the `.env` file for connection details +- Parse the `.env` file for connection details (including `NEO4J_DATABASE` when present) - Configure the generated project to use your Aura instance - Set `neo4j_type` to `aura` automatically +:::caution Database name + +Aura instances provisioned through the Aura API or CLI often name their +database after the instance id rather than the literal string `neo4j`. If your +`.env` download includes a `NEO4J_DATABASE` line, it is imported automatically; +otherwise pass `--neo4j-database ` explicitly. Without it, the app +connects fine but every memory write targets a database that doesn't exist — +the `/health` endpoint will report `"memory": false` with a classified +`memory_error` when this happens. + +::: + ## Step 4: Verify the Connection After scaffolding, verify the connection: diff --git a/docs/docs/reference/cli-options.md b/docs/docs/reference/cli-options.md index 7220fa2..0dad2e8 100644 --- a/docs/docs/reference/cli-options.md +++ b/docs/docs/reference/cli-options.md @@ -23,7 +23,8 @@ create-context-graph [PROJECT_NAME] [OPTIONS] |--------|------|---------|-------------| | `--domain` | `string` | *(wizard)* | Domain ID (e.g., `healthcare`, `financial-services`). Use `--list-domains` to see all. | | `--framework` | `choice` | *(wizard)* | Agent framework: `pydanticai`, `claude-agent-sdk`, `strands`, `google-adk`, `openai-agents`, `langgraph`, `crewai`, `anthropic-tools`. | -| `--custom-domain` | `string` | -- | Natural language domain description. Requires `--anthropic-api-key`. | +| `--custom-domain` | `string` | -- | Natural language domain description. Requires `--anthropic-api-key`. Mutually exclusive with `--ontology-file`. | +| `--ontology-file` | `path` | -- | Path to a hand-written domain ontology YAML. Scaffolds directly from the file (no LLM call); overrides `--domain`, and the YAML is copied into the project as `data/ontology.yaml`. | | `--output-dir` | `path` | `./` | Directory for generated project. | | `--with-mcp` | `flag` | `false` | Generate MCP server config for Claude Desktop. | | `--mcp-profile` | `choice` | `extended` | MCP tool profile: `core` (6 tools) or `extended` (16 tools). | @@ -104,7 +105,8 @@ All of the following imply `--self-hosted` if passed without `--nams-api-key`. | `--neo4j-uri` | `string` | `$NEO4J_URI` or `neo4j://localhost:7687` | Neo4j Bolt connection URI. | | `--neo4j-username` | `string` | `$NEO4J_USERNAME` or `neo4j` | Neo4j username. | | `--neo4j-password` | `string` | `$NEO4J_PASSWORD` or `password` | Neo4j password. | -| `--neo4j-aura-env` | `path` | -- | Path to Aura `.env` file. Auto-sets `neo4j_type=aura`. | +| `--neo4j-database` | `string` | `$NEO4J_DATABASE` or blank | Database name. Blank defers to the driver default (`neo4j`) — set this for instances whose database has a different name (e.g. Aura instances provisioned via the Aura API/CLI, which often name it after the instance id). Threaded through the generated app, `--ingest` seeding, and `--reset-database`. | +| `--neo4j-aura-env` | `path` | -- | Path to Aura `.env` file. Auto-sets `neo4j_type=aura`. Also imports `NEO4J_DATABASE` when the file contains one (an explicit `--neo4j-database` wins). | | `--neo4j-local` | `flag` | `false` | Use `@johnymontana/neo4j-local` (no Docker). | ### API Keys @@ -316,6 +318,7 @@ The following environment variables are read as defaults for their corresponding | `NEO4J_URI` | `--neo4j-uri` | | `NEO4J_USERNAME` | `--neo4j-username` | | `NEO4J_PASSWORD` | `--neo4j-password` | +| `NEO4J_DATABASE` | `--neo4j-database` | | `ANTHROPIC_API_KEY` | `--anthropic-api-key` | | `OPENAI_API_KEY` | `--openai-api-key` | | `GOOGLE_API_KEY` | `--google-api-key` | diff --git a/docs/docs/reference/generated-project-structure.md b/docs/docs/reference/generated-project-structure.md index 07f7fc0..ef4e9d0 100644 --- a/docs/docs/reference/generated-project-structure.md +++ b/docs/docs/reference/generated-project-structure.md @@ -41,7 +41,8 @@ my-app/ │ │ ├── constants.py # Shared constants (index names, graph projections) │ │ ├── context_graph_client.py # Neo4j read/write client with query timeouts │ │ ├── gds_client.py # Neo4j Graph Data Science client (label-validated) -│ │ └── vector_client.py # Vector search client with logging +│ │ ├── vector_client.py # Vector search client with logging +│ │ └── ontology_document.json # NAMS ontology doc — activated (or created, for custom domains) on connect │ ├── tests/ │ │ ├── __init__.py │ │ └── test_routes.py # Generated test scaffold (health, scenarios) @@ -275,9 +276,14 @@ Generated demo data in a structured format: NEO4J_URI=neo4j://localhost:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD=password +NEO4J_DATABASE= ANTHROPIC_API_KEY= ``` +`NEO4J_DATABASE` is blank by default (the driver default `neo4j` is used). +Set it when your instance's database has a different name — the generated +app's Cypher sessions, memory layer, and `make import` all honor it. + ### `docker-compose.yml` Defines a Neo4j container with APOC and GDS plugins, mapped to ports 7474 (browser) and 7687 (Bolt). diff --git a/docs/docs/whats-new.md b/docs/docs/whats-new.md index d9fcdce..b89c72d 100644 --- a/docs/docs/whats-new.md +++ b/docs/docs/whats-new.md @@ -7,7 +7,37 @@ title: "What's New" Recent additions and changes to create-context-graph and its documentation. -## v0.13.0 (Current) — v0.12.0 feedback report fixes +## v0.14.0 (Current) — community PR hardening + +Integrates five community PRs (#52, #56, #58, #59, #60), closes the gaps found reviewing them, and adds ~90 tests across unit, app-level, integration, and generated-project suites. + +### New Features + +- **`NEO4J_DATABASE` support** (self-hosted backend). New `--neo4j-database` flag / `NEO4J_DATABASE` env var, threaded through the CLI, wizard, Aura `.env` import, generated `.env`, memory layer, raw Cypher sessions, scaffold-time `--ingest`/`--reset-database`, and the generated `make import` script. Set it for Aura instances whose database isn't literally named `neo4j` (common when provisioned via the Aura API/CLI). See [Neo4j Aura guide](/docs/how-to/use-neo4j-aura). +- **Memory failures surface in `/health`.** The bolt health response gains `memory`, `memory_error`, and `memory_error_detail`; live `store_message()` failures (e.g. a wrong database name) flip the status to `degraded` instead of failing silently behind an "ok". +- **Agent tools and `POST /cypher` work on NAMS.** `execute_cypher()` dispatches read queries through the NAMS query API with result-shape coercion; routes return 503 with guidance when the NAMS client never connected. +- **`--ontology-file` scaffolds from hand-written YAML** — documented since v0.12 but previously unimplemented (issue #50). The file's declared domain id drives the scaffold and the YAML is copied to `data/ontology.yaml`. See [Add a Custom Domain](/docs/how-to/add-custom-domain). +- **Custom domains load by id** (issue #30). Anything `--list-domains` advertises — including domains saved to `~/.create-context-graph/custom-domains/` — now actually loads. + +### Bug Fixes + +- Schema DDL splitter rewrite: five indexes/constraints that sat behind comment headers (`person_name`, `document_title`, `document_domain`, `document_name_unique`, `local_file_fulltext`) were silently never created by `make seed`/ingest, and one comment fragment executed as garbage Cypher. A shared comment-aware `split_cypher_statements()` fixes every consumer. +- Domains without `demo_scenarios` (or with an empty `prompts` list) no longer crash scaffold generation. +- `.env.example` documents `NEO4J_DATABASE`. + +### NAMS domain ontology activation + +Generated apps now bind their NAMS workspace to the domain ontology on startup: NAMS pre-registers every bundled domain server-side but leaves workspaces on a generic `nams-default` ontology until one is activated — which nothing did before. Catalog domains activate in one call; custom domains are created from the scaffold's new `ontology_document.json` and then activated. The same binding runs before `make import` and CLI `--ingest`, so all stored data is stamped with the domain's ontology version and server-side extraction uses the domain vocabulary. See the [NAMS guide](/docs/how-to/use-nams). + +### Live NAMS fixes + +The whole NAMS flow was verified against the production service with `neo4j-agent-memory` 0.5.0, which surfaced and fixed five breaks: conversation memory silently failing on every message (the service only accepts conversation ids it minted — generated apps now create conversations per session), document/body ingest rejected wholesale (`role="document"` isn't a valid role — now `role="user"` with a metadata kind marker over server-created channels), empty document browser and schema view (the service coerces `OBJECT`/`EVENT` types to `custom` — adapters are now cypher-first using the scaffold's own description markers), dead graph expand (`get_entity(id)` doesn't exist in the 0.5.x client — resolved via the cypher API), and a NAMS "reset" that always reported success while deleting nothing (no delete API exists — `make reset` now says so and points at the [NAMS dashboard](https://memory.neo4jlabs.com)). + +See the [CHANGELOG](https://github.com/neo4j-labs/create-context-graph/blob/main/CHANGELOG.md) for the full list, including the v0.13.1 feedback-triage release (dry-run credential gate fix, `/schema/models` endpoint, composite-key regression tests). + +--- + +## v0.13.0 — v0.12.0 feedback report fixes Addresses the May 19, 2026 v0.12.0 feedback report. The headline fix is a runtime bug on the `--self-hosted` connector ingest path that NAMS users never hit; everything else is a cluster of smaller-but-real frontend, backend, and documentation gaps. Test suite: 1,335 passing, 231 skipped (matrix/integration unchanged from baseline). diff --git a/pyproject.toml b/pyproject.toml index 8f36dec..2ac054e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "create-context-graph" -version = "0.13.1" +version = "0.14.0" description = "Interactive CLI scaffolding tool for domain-specific context graph applications" readme = "README.md" license = "Apache-2.0" @@ -68,6 +68,9 @@ dev = [ "fastapi>=0.115", "httpx>=0.27", "pydantic-settings>=2.0", + # Pinned so local `make lint` and CI enforce the same rules — an unpinned + # ruff broke CI when 0.16 expanded its default rule set. + "ruff>=0.16,<0.17", ] all = [ "create-context-graph[generate,ingest,connectors]", @@ -91,3 +94,14 @@ markers = [ "integration: marks tests requiring Neo4j (run with --integration)", "functional: marks functional tests that ingest realistic on-disk fixtures (run with --functional)", ] + +[tool.ruff] +target-version = "py311" + +[tool.ruff.lint] +# Pin the enforced rule set explicitly. Ruff 0.16 expanded its default rule +# selection, which silently changed what CI lints and broke the build — an +# unpinned selection means upstream releases decide our standards. This is +# the pre-0.16 default set the codebase is written against; expand it +# deliberately (one rule family at a time), not via default drift. +select = ["E4", "E7", "E9", "F"] diff --git a/scripts/e2e_smoke_test.py b/scripts/e2e_smoke_test.py index 182ef52..899ba1a 100644 --- a/scripts/e2e_smoke_test.py +++ b/scripts/e2e_smoke_test.py @@ -125,8 +125,10 @@ def wait_for_backend(timeout: int = 60) -> bool: try: res = requests.get(HEALTH_URL, timeout=5) if res.ok: - status = res.json().get("status", "unknown") + body = res.json() + status = body.get("status", "unknown") log(f"Backend ready (status={status})", "OK") + _check_health_shape(body) return True except requests.ConnectionError: pass @@ -135,6 +137,22 @@ def wait_for_backend(timeout: int = 60) -> bool: return False +def _check_health_shape(body: dict) -> None: + """Assert the v0.14.0 /health contract (shape only — a degraded memory + layer is reported, not failed, since chat still works without it).""" + backend = body.get("memory_backend") + if backend == "bolt": + for key in ("neo4j", "memory"): + if key not in body: + raise AssertionError(f"/health missing '{key}' field on bolt backend: {body}") + if body.get("status") == "degraded": + log(f"Memory degraded: {body.get('memory_error')} " + f"({body.get('memory_error_detail')})", "WARN") + elif backend == "nams": + if "nams" not in body: + raise AssertionError(f"/health missing 'nams' field on NAMS backend: {body}") + + def send_prompt(prompt: str, session_id: str | None = None) -> dict: """Send a prompt to the chat API and return the response.""" payload: dict = {"message": prompt} diff --git a/src/create_context_graph/__init__.py b/src/create_context_graph/__init__.py index 6aa3997..bab2911 100644 --- a/src/create_context_graph/__init__.py +++ b/src/create_context_graph/__init__.py @@ -14,4 +14,4 @@ """Create Context Graph - Interactive CLI scaffolding tool for domain-specific context graph applications.""" -__version__ = "0.13.1" +__version__ = "0.14.0" diff --git a/src/create_context_graph/__pycache__/__init__.cpython-311.pyc b/src/create_context_graph/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 3326d90..0000000 Binary files a/src/create_context_graph/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/src/create_context_graph/__pycache__/__main__.cpython-311.pyc b/src/create_context_graph/__pycache__/__main__.cpython-311.pyc deleted file mode 100644 index a784470..0000000 Binary files a/src/create_context_graph/__pycache__/__main__.cpython-311.pyc and /dev/null differ diff --git a/src/create_context_graph/__pycache__/cli.cpython-311.pyc b/src/create_context_graph/__pycache__/cli.cpython-311.pyc deleted file mode 100644 index cfda38c..0000000 Binary files a/src/create_context_graph/__pycache__/cli.cpython-311.pyc and /dev/null differ diff --git a/src/create_context_graph/__pycache__/config.cpython-311.pyc b/src/create_context_graph/__pycache__/config.cpython-311.pyc deleted file mode 100644 index dc098ab..0000000 Binary files a/src/create_context_graph/__pycache__/config.cpython-311.pyc and /dev/null differ diff --git a/src/create_context_graph/__pycache__/generator.cpython-311.pyc b/src/create_context_graph/__pycache__/generator.cpython-311.pyc deleted file mode 100644 index 6cea75c..0000000 Binary files a/src/create_context_graph/__pycache__/generator.cpython-311.pyc and /dev/null differ diff --git a/src/create_context_graph/__pycache__/ontology.cpython-311.pyc b/src/create_context_graph/__pycache__/ontology.cpython-311.pyc deleted file mode 100644 index e9967b5..0000000 Binary files a/src/create_context_graph/__pycache__/ontology.cpython-311.pyc and /dev/null differ diff --git a/src/create_context_graph/__pycache__/renderer.cpython-311.pyc b/src/create_context_graph/__pycache__/renderer.cpython-311.pyc deleted file mode 100644 index f80e75e..0000000 Binary files a/src/create_context_graph/__pycache__/renderer.cpython-311.pyc and /dev/null differ diff --git a/src/create_context_graph/cli.py b/src/create_context_graph/cli.py index 4fa2bf6..8434b88 100644 --- a/src/create_context_graph/cli.py +++ b/src/create_context_graph/cli.py @@ -326,6 +326,12 @@ def main( # Handle custom domain generation (non-interactive) custom_domain_yaml = None custom_ontology = None + if custom_domain and ontology_file: + console.print( + "[red]Error:[/red] --custom-domain and --ontology-file are mutually " + "exclusive — pass one or the other." + ) + raise SystemExit(1) if custom_domain: if not anthropic_api_key: console.print("[red]Error:[/red] --anthropic-api-key is required for custom domain generation.") @@ -355,6 +361,10 @@ def main( console.print(f"[red]Error:[/red] Failed to load ontology file '{ontology_file}': {e}") raise SystemExit(1) domain = custom_ontology.domain.id + # Carry the raw YAML so the renderer writes it to data/ontology.yaml — + # hand-written domains aren't bundled, so the copy-by-domain-id path + # would silently produce a scaffold with no ontology file. + custom_domain_yaml = Path(ontology_file).read_text() # Handle Neo4j Aura .env import if neo4j_aura_env: @@ -525,6 +535,15 @@ def main( from create_context_graph.wizard import run_wizard config = run_wizard(self_hosted=bolt_flag_used) + if custom_ontology: + # --ontology-file was passed but the wizard collected the rest; + # the hand-written ontology wins over the wizard's domain pick. + console.print( + f"[dim]Using ontology from {ontology_file} " + f"(domain: {custom_ontology.domain.id})[/dim]" + ) + config.domain = custom_ontology.domain.id + config.custom_domain_yaml = custom_domain_yaml # Resolve output directory out = Path(output_dir) if output_dir else Path.cwd() / config.project_slug diff --git a/src/create_context_graph/ingest.py b/src/create_context_graph/ingest.py index 16c71d7..6f62cc4 100644 --- a/src/create_context_graph/ingest.py +++ b/src/create_context_graph/ingest.py @@ -24,7 +24,8 @@ ``add_relationship`` against the NAMS REST API a one-shot migration can drain those blocks into native edges. Documents are dual-tracked: ``add_entity(Document, ...)`` for the queryable long-term node AND - ``short_term.add_message(role="document")`` to feed the NAMS extractor. + ``short_term.add_message`` (role="user", metadata kind="document", + addressed to a server-created conversation id) to feed the NAMS extractor. Entity records whose connector declares a ``BODY_FIELDS`` mapping also have their body field sent through ``add_message`` for the same reason. Decision traces go through the reasoning REST API unchanged. @@ -48,7 +49,12 @@ from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn -from create_context_graph.ontology import DomainOntology, generate_cypher_schema +from create_context_graph.ontology import ( + DomainOntology, + build_nams_ontology_document, + generate_cypher_schema, + split_cypher_statements, +) if TYPE_CHECKING: from create_context_graph.config import ProjectConfig @@ -210,6 +216,61 @@ def _resolve_body( # --------------------------------------------------------------------------- +async def ensure_nams_ontology( + client: Any, + domain_id: str, + ontology_document: dict | None = None, +) -> str: + """Make the workspace's active NAMS ontology match ``domain_id``. + + NAMS auto-binds every workspace to the generic ``nams-default`` ontology + until an explicit one is activated, and pre-registers an ontology for + each bundled domain. Resolution order: + + 1. already active → ``"already-active"`` (no-op) + 2. catalog match by name → activate its latest version (``"activated"``) + 3. ``ontology_document`` set → create it, then activate (``"created"`` — + the custom-domain path; the document comes from + :func:`build_nams_ontology_document`) + + Best-effort by design: returns ``"unavailable"`` on any failure or when + nothing matches and no document was provided — memory writes still work + against the default ontology, just without domain-shaped extraction. + All calls use keyword arguments so the parity contract test can record + them uniformly. + """ + try: + active = await client.ontology.get_active() + active_domain = getattr( + getattr(getattr(active, "document", None), "domain", None), "id", None + ) + if active_domain == domain_id: + return "already-active" + + summaries = await client.ontology.list() + match = next( + (s for s in summaries if getattr(s, "name", None) == domain_id), None + ) + if match is not None: + full = await client.ontology.get(ontology_id=match.id) + versions = getattr(full, "versions", None) or [] + if versions: + latest = max(versions, key=lambda v: getattr(v, "revision", 0) or 0) + await client.ontology.activate(version_id=latest.id) + return "activated" + + if ontology_document is not None: + created = await client.ontology.create( + name=domain_id, schema=ontology_document + ) + await client.ontology.activate(version_id=created.id) + return "created" + return "unavailable" + except Exception as e: # noqa: BLE001 — ontology is an enhancement, not a gate + console.print(f" [yellow]NAMS ontology activation skipped:[/yellow] {e}") + return "unavailable" + + async def run_nams_ingest( client: Any, fixture_data: dict, @@ -245,6 +306,36 @@ def _emit(stage: str, **payload: Any) -> None: if on_event is not None: on_event(stage, payload) + # Stage 0: bind the workspace to the domain ontology (best-effort) so + # data is stamped with — and extraction speaks — the domain vocabulary + # instead of nams-default. + ontology_status = await ensure_nams_ontology( + client, domain_id, build_nams_ontology_document(ontology) + ) + _emit("ontology", status=ontology_status) + + # NAMS only accepts messages addressed to conversation ids IT minted at + # create time — posting to a client-chosen session string 404s with + # "conversation not found". Channels are created lazily (one per message + # stream) and the server-assigned id is what add_message must target. + _channel_ids: dict[str, str | None] = {} + + async def _message_channel(session_hint: str) -> str | None: + """Return the server conversation id for ``session_hint``, or None if + the channel can't be created (message writes are then skipped).""" + if session_hint not in _channel_ids: + try: + conv = await client.short_term.create_conversation( + session_id=session_hint + ) + _channel_ids[session_hint] = str(getattr(conv, "id", "") or "") or None + except Exception as exc: # noqa: BLE001 + failures.append({ + "kind": "conversation", "name": session_hint, "error": str(exc), + }) + _channel_ids[session_hint] = None + return _channel_ids[session_hint] + # Stage 1: entities with ccg-edges encoded into description. entities = fixture_data.get("entities", {}) for label, items in entities.items(): @@ -276,11 +367,17 @@ def _emit(stage: str, **payload: Any) -> None: if body is None: continue try: + channel = await _message_channel(f"bodies-{domain_id}") + if channel is None: + raise RuntimeError("bodies conversation unavailable") + # role must be user/assistant/system — NAMS rejects custom + # roles; the metadata kind marks this as extraction fuel. await client.short_term.add_message( - session_id=f"bodies-{domain_id}", - role="document", + session_id=channel, + role="user", content=body, metadata={ + "kind": "entity-body", "entity_name": name, "entity_label": label, "domain": domain_id, @@ -317,11 +414,15 @@ def _emit(stage: str, **payload: Any) -> None: entity_type="OBJECT", description=doc_description, ) + channel = await _message_channel(doc_session) + if channel is None: + raise RuntimeError("docs conversation unavailable") await client.short_term.add_message( - session_id=doc_session, - role="document", + session_id=channel, + role="user", content=content, metadata={ + "kind": "document", "title": title, "template_id": doc.get("template_id", ""), "template_name": doc.get("template_name", ""), @@ -396,11 +497,25 @@ async def _ingest_with_nams( " [dim][1/3] NAMS owns schema — skipping CREATE CONSTRAINT statements[/dim]" ) task = progress.add_task("[2/3] Ingesting entities + documents (NAMS)...", total=None) + + def _on_stage(stage: str, payload: dict) -> None: + if stage == "ontology": + label = { + "already-active": "domain ontology already active", + "activated": "activated domain ontology", + "created": "created + activated custom domain ontology", + "unavailable": "using NAMS default ontology", + }.get(payload.get("status", ""), payload.get("status", "")) + console.print( + f" [dim][0/3] {label} ({ontology.domain.id})[/dim]" + ) + counts = await run_nams_ingest( client=client, fixture_data=fixture_data, ontology=ontology, body_fields=body_fields, + on_event=_on_stage, ) progress.update( task, @@ -444,18 +559,25 @@ async def _ingest_with_memory_client( neo4j_uri: str, neo4j_username: str, neo4j_password: str, + neo4j_database: str = "", ) -> None: - """Ingest data using neo4j-agent-memory MemoryClient (bolt backend).""" + """Ingest data using neo4j-agent-memory MemoryClient (bolt backend). + + ``neo4j_database`` of ``""`` defers to the library default ("neo4j"); + set it for instances whose database isn't literally named "neo4j". + """ from pydantic import SecretStr from neo4j_agent_memory import MemoryClient, MemorySettings - settings = MemorySettings( - neo4j={ - "uri": neo4j_uri, - "username": neo4j_username, - "password": SecretStr(neo4j_password), - } - ) + neo4j_config: dict = { + "uri": neo4j_uri, + "username": neo4j_username, + "password": SecretStr(neo4j_password), + } + if neo4j_database: + neo4j_config["database"] = neo4j_database + + settings = MemorySettings(neo4j=neo4j_config) async with MemoryClient(settings) as client: with Progress( @@ -467,14 +589,12 @@ async def _ingest_with_memory_client( # Step 1: Apply schema task = progress.add_task("[1/4] Applying schema...", total=None) cypher_schema = generate_cypher_schema(ontology) - for statement in cypher_schema.split(";"): - stmt = statement.strip() - if stmt and not stmt.startswith("//"): - try: - await client.graph.execute_write(stmt) - except Exception as e: - if "already exists" not in str(e).lower(): - console.print(f" [yellow]Warning:[/yellow] Schema: {e}") + for stmt in split_cypher_statements(cypher_schema): + try: + await client.graph.execute_write(stmt) + except Exception as e: + if "already exists" not in str(e).lower(): + console.print(f" [yellow]Warning:[/yellow] Schema: {e}") progress.update(task, description="[1/4] Schema applied") # Step 2: Ingest entities @@ -616,6 +736,7 @@ async def _ingest_with_driver( neo4j_uri: str, neo4j_username: str, neo4j_password: str, + neo4j_database: str = "", ) -> None: """Fallback: ingest using neo4j driver directly (no neo4j-agent-memory).""" from neo4j import AsyncGraphDatabase @@ -624,6 +745,8 @@ async def _ingest_with_driver( neo4j_uri, auth=(neo4j_username, neo4j_password), ) + # None defers to the server default database ("neo4j" unless reconfigured). + session_db = neo4j_database or None try: await driver.verify_connectivity() @@ -639,21 +762,19 @@ async def _ingest_with_driver( task = progress.add_task("[1/5] Applying schema...", total=None) cypher_schema = generate_cypher_schema(ontology) - async with driver.session() as session: - for statement in cypher_schema.split(";"): - stmt = statement.strip() - if stmt and not stmt.startswith("//"): - try: - await session.run(stmt) - except Exception as e: - if "already exists" not in str(e).lower(): - console.print(f" [yellow]Warning:[/yellow] Schema: {e}") + async with driver.session(database=session_db) as session: + for stmt in split_cypher_statements(cypher_schema): + try: + await session.run(stmt) + except Exception as e: + if "already exists" not in str(e).lower(): + console.print(f" [yellow]Warning:[/yellow] Schema: {e}") progress.update(task, description="[1/5] Schema applied") task = progress.add_task("[2/5] Creating entities...", total=None) entity_count = 0 entities = fixture_data.get("entities", {}) - async with driver.session() as session: + async with driver.session(database=session_db) as session: for label, items in entities.items(): try: safe_label = _require_safe_cypher_identifier(label, "label") @@ -674,7 +795,7 @@ async def _ingest_with_driver( task = progress.add_task("[3/5] Creating relationships...", total=None) rel_count = 0 relationships = fixture_data.get("relationships", []) - async with driver.session() as session: + async with driver.session(database=session_db) as session: for rel in relationships: try: source_label = _require_safe_cypher_identifier( @@ -703,7 +824,7 @@ async def _ingest_with_driver( task = progress.add_task("[4/5] Creating documents...", total=None) doc_count = 0 documents = fixture_data.get("documents", []) - async with driver.session() as session: + async with driver.session(database=session_db) as session: for doc in documents: try: await session.run( @@ -741,7 +862,7 @@ async def _ingest_with_driver( task = progress.add_task("[5/5] Creating decision traces...", total=None) trace_count = 0 traces = fixture_data.get("traces", []) - async with driver.session() as session: + async with driver.session(database=session_db) as session: for trace_data in traces: try: await session.run( @@ -785,49 +906,57 @@ async def _ingest_with_driver( # --------------------------------------------------------------------------- -def reset_neo4j(neo4j_uri: str, neo4j_username: str, neo4j_password: str) -> None: +def reset_neo4j( + neo4j_uri: str, + neo4j_username: str, + neo4j_password: str, + neo4j_database: str = "", +) -> None: """Clear all data from Neo4j (bolt backend).""" from neo4j import GraphDatabase driver = GraphDatabase.driver(neo4j_uri, auth=(neo4j_username, neo4j_password)) - with driver.session() as session: + with driver.session(database=neo4j_database or None) as session: session.run("MATCH (n) DETACH DELETE n") driver.close() async def _reset_nams(api_key: str, endpoint: str) -> None: - """Best-effort reset on NAMS: list entities and delete one-by-one. + """Report what a NAMS reset *would* remove — deletion isn't possible. - Slow (one REST call per entity) — print a warning. Conversations and - reasoning traces are reset via session-level clear calls. + Neither the NAMS REST API nor neo4j-agent-memory (through 0.5.x) exposes + an entity delete endpoint (``long_term.delete_entity`` does not exist, + and the cypher API is read-only). Earlier versions of this function + silently reported "0 entities removed" by swallowing AttributeErrors; + being honest beats pretending. """ from neo4j_agent_memory import MemoryClient, MemorySettings, NamsConfig from pydantic import SecretStr - console.print( - " [yellow]NAMS reset is per-entity (slow). For fast reset, use --self-hosted.[/yellow]" - ) - settings = MemorySettings( backend="nams", nams=NamsConfig(api_key=SecretStr(api_key), endpoint=endpoint), ) + entity_count: int | None = None async with MemoryClient(settings) as client: - deleted = 0 try: - entities = await client.long_term.search_entities(query="", limit=1000) - for ent in entities: - ent_id = getattr(ent, "id", None) or getattr(ent, "entity_id", None) - if ent_id is None: - continue - try: - await client.long_term.delete_entity(ent_id) - deleted += 1 - except Exception: - pass - except Exception as e: - console.print(f" [yellow]Reset partial:[/yellow] {e}") - console.print(f" [green]Reset complete:[/green] {deleted} entities removed") + rows = await client.query.cypher( + "MATCH (n) WHERE n.id IS NOT NULL RETURN count(n) AS n", {} + ) + if rows and isinstance(rows[0], dict): + entity_count = int(rows[0].get("n", 0)) + except Exception: + pass + counted = f"{entity_count} stored entities" if entity_count is not None else "stored data" + console.print( + f" [yellow]NAMS reset is not available:[/yellow] the NAMS REST API " + f"(and neo4j-agent-memory 0.5.x) exposes no delete endpoint, so the " + f"CLI cannot remove {counted}." + ) + console.print( + " Manage stored data at https://memory.neo4jlabs.com, or use a " + "--self-hosted scaffold for full control." + ) def reset_memory_store(config: "ProjectConfig") -> None: @@ -838,7 +967,12 @@ def reset_memory_store(config: "ProjectConfig") -> None: return asyncio.run(_reset_nams(config.nams_api_key, config.nams_endpoint)) else: - reset_neo4j(config.neo4j_uri, config.neo4j_username, config.neo4j_password) + reset_neo4j( + config.neo4j_uri, + config.neo4j_username, + config.neo4j_password, + config.neo4j_database, + ) # --------------------------------------------------------------------------- @@ -929,6 +1063,7 @@ def ingest_data( _ingest_with_memory_client( fixture_data, ontology, config.neo4j_uri, config.neo4j_username, config.neo4j_password, + neo4j_database=config.neo4j_database, ) ) except ImportError: @@ -937,5 +1072,6 @@ def ingest_data( _ingest_with_driver( fixture_data, ontology, config.neo4j_uri, config.neo4j_username, config.neo4j_password, + neo4j_database=config.neo4j_database, ) ) diff --git a/src/create_context_graph/neo4j_validator.py b/src/create_context_graph/neo4j_validator.py index d031ae6..28393d6 100644 --- a/src/create_context_graph/neo4j_validator.py +++ b/src/create_context_graph/neo4j_validator.py @@ -20,13 +20,20 @@ from neo4j.exceptions import ServiceUnavailable, AuthError -def validate_connection(uri: str, username: str, password: str) -> tuple[bool, str]: - """Test Neo4j connection and return (success, message).""" +def validate_connection( + uri: str, username: str, password: str, database: str = "" +) -> tuple[bool, str]: + """Test Neo4j connection and return (success, message). + + ``database`` of ``""`` runs the test query against the server default + database; pass a name to validate a specific database (e.g. Aura + instances whose database isn't named "neo4j"). + """ try: driver = GraphDatabase.driver(uri, auth=(username, password)) driver.verify_connectivity() # Quick test query - with driver.session() as session: + with driver.session(database=database or None) as session: result = session.run("RETURN 1 AS n") result.single() driver.close() diff --git a/src/create_context_graph/ontology.py b/src/create_context_graph/ontology.py index 6298d95..099e4f5 100644 --- a/src/create_context_graph/ontology.py +++ b/src/create_context_graph/ontology.py @@ -363,6 +363,51 @@ def load_domain_from_path(path: Path) -> DomainOntology: } +def build_nams_ontology_document(ontology: DomainOntology) -> dict: + """Build the document shape the NAMS ontology API expects. + + The server's ``OntologyDocument`` is ``{domain, entity_types, + relationships}`` with field-for-field the same sub-models this package + defines (labels, pole types, property defs). App-side sections + (``document_templates``, ``decision_traces``, ``demo_scenarios``, + ``agent_tools``, ``system_prompt``, ``visualization``) are not part of + the server schema and are excluded. + """ + return { + "domain": ontology.domain.model_dump(), + "entity_types": [et.model_dump() for et in ontology.entity_types], + "relationships": [rel.model_dump() for rel in ontology.relationships], + } + + +def split_cypher_statements(script: str) -> list[str]: + """Split a Cypher script into executable statements. + + Naive ``script.split(";")`` mis-handles the schema scripts this package + generates in two ways: + + * A ``;`` inside a ``//`` comment splits mid-comment, so the comment's + tail executes as a garbage statement (``CypherSyntaxError``). + * A fragment that *starts* with a comment header gets dropped entirely by + ``if not stmt.startswith("//")`` checks — silently skipping the real + ``CREATE INDEX``/``CREATE CONSTRAINT`` that follows the header. + + This helper removes comment lines first, then splits, so every real + statement survives and nothing else does. (Limitation: a ``;`` inside a + string literal or a trailing same-line comment would still split — + ``generate_cypher_schema`` emits neither.) + """ + code_lines = [ + line for line in script.splitlines() if not line.strip().startswith("//") + ] + statements = [] + for fragment in "\n".join(code_lines).split(";"): + stmt = fragment.strip() + if stmt: + statements.append(stmt) + return statements + + def generate_cypher_schema(ontology: DomainOntology) -> str: """Generate Cypher constraints and indexes from the ontology.""" lines = [ @@ -418,7 +463,7 @@ def generate_cypher_schema(ontology: DomainOntology) -> str: lines.append("CREATE FULLTEXT INDEX local_file_fulltext IF NOT EXISTS FOR (n:Document|Section) ON EACH [n.title, n.description];") lines.append("") lines.append("// Vector index for semantic search with pre-filtering (Neo4j 2026.01+)") - lines.append("// Create after embeddings are generated; dimensions must match your embed model.") + lines.append("// Create after embeddings are generated — dimensions must match your embed model.") lines.append("// CREATE VECTOR INDEX local_file_embedding IF NOT EXISTS") lines.append("// FOR (n:Document|Section) ON n.embedding") lines.append("// WITH [n.domain, n.fileExtension, n.loadedAt, n.createdAt, n.modifiedAt,") diff --git a/src/create_context_graph/renderer.py b/src/create_context_graph/renderer.py index 104d2ab..6894a03 100644 --- a/src/create_context_graph/renderer.py +++ b/src/create_context_graph/renderer.py @@ -16,6 +16,7 @@ from __future__ import annotations +import json import re import shutil from importlib.resources import files @@ -28,6 +29,7 @@ from create_context_graph.ontology import ( DomainOntology, _get_domains_path, + build_nams_ontology_document, generate_cypher_schema, generate_pydantic_models, generate_visualization_config, @@ -428,6 +430,14 @@ def _render_backend(self, backend_dir: Path, ctx: dict) -> None: (backend_dir / "app").mkdir(parents=True, exist_ok=True) (backend_dir / "app" / "__init__.py").write_text("") + # NAMS ontology document — connect_memory() activates the matching + # server-side ontology by domain id, and falls back to creating one + # from this file when the domain isn't in the NAMS catalog (custom + # domains). Written for both backends; only the NAMS path reads it. + (backend_dir / "app" / "ontology_document.json").write_text( + json.dumps(build_nams_ontology_document(self.ontology), indent=2) + "\n" + ) + # Framework-specific agent template. Only fall back to the stub when # the framework directory doesn't exist (e.g. a new framework key was # added without a template). Template-rendering errors — Jinja syntax, diff --git a/src/create_context_graph/templates/backend/connectors/import_data.py.j2 b/src/create_context_graph/templates/backend/connectors/import_data.py.j2 index 5c08d21..d82c7b9 100644 --- a/src/create_context_graph/templates/backend/connectors/import_data.py.j2 +++ b/src/create_context_graph/templates/backend/connectors/import_data.py.j2 @@ -21,7 +21,7 @@ NAMS write shape: (NAMS REST has no add_relationship today; the block migrates cleanly when it does). * Documents → dual-tracked: ``add_entity(name=title, type=OBJECT)`` AND - ``short_term.add_message(role="document")`` so the NAMS extractor sees + ``short_term.add_message`` (role="user", metadata kind="document") so the NAMS extractor sees the prose. * Entity records with a body field (declared per-connector via ``BODY_FIELDS = {label: property}``) also flow through ``add_message``. @@ -211,6 +211,55 @@ def _require_safe_cypher_identifier(value: str, kind: str) -> str: raise ValueError(f"Unsafe Cypher {kind}: {value!r}") +def _load_ontology_document() -> dict[str, Any] | None: + """Load the scaffold's NAMS ontology document (written at generation time).""" + path = Path(__file__).resolve().parent.parent / "app" / "ontology_document.json" + try: + return json.loads(path.read_text()) + except Exception: # noqa: BLE001 — missing/corrupt file just disables create + return None + + +async def _ensure_nams_ontology(client: Any) -> str: + """Bind the workspace to this app's domain ontology (best-effort). + + Mirrors ``ensure_nams_ontology`` in src/create_context_graph/ingest.py — + kept in lockstep via test_nams_ingest_parity.py. Already active → no-op; + catalog match by domain id → activate latest version; otherwise create + from ``app/ontology_document.json`` and activate. Never raises. + """ + domain_id = settings.domain_id + try: + active = await client.ontology.get_active() + active_domain = getattr( + getattr(getattr(active, "document", None), "domain", None), "id", None + ) + if active_domain == domain_id: + return "already-active" + + summaries = await client.ontology.list() + match = next( + (s for s in summaries if getattr(s, "name", None) == domain_id), None + ) + if match is not None: + full = await client.ontology.get(ontology_id=match.id) + versions = getattr(full, "versions", None) or [] + if versions: + latest = max(versions, key=lambda v: getattr(v, "revision", 0) or 0) + await client.ontology.activate(version_id=latest.id) + return "activated" + + doc = _load_ontology_document() + if doc is not None: + created = await client.ontology.create(name=domain_id, schema=doc) + await client.ontology.activate(version_id=created.id) + return "created" + return "unavailable" + except Exception as exc: # noqa: BLE001 — ontology is an enhancement, not a gate + logger.warning("NAMS ontology activation skipped: %s", exc) + return "unavailable" + + async def _ingest_via_nams(data: dict[str, Any], body_fields: dict[str, str]) -> dict[str, int]: """Write data into NAMS. Mirrors src/create_context_graph/ingest.py run_nams_ingest() — kept in lockstep via test_nams_ingest_parity.py.""" @@ -229,6 +278,32 @@ async def _ingest_via_nams(data: dict[str, Any], body_fields: dict[str, str]) -> counts = {"entities": 0, "documents": 0, "bodies": 0, "traces": 0, "edges_encoded": 0, "failures": 0} async with MemoryClient(mem_settings) as client: + # Stage 0: bind the workspace to the domain ontology (best-effort) + # so data is stamped with — and extraction speaks — the domain + # vocabulary instead of nams-default. + ontology_status = await _ensure_nams_ontology(client) + logger.info("NAMS ontology: %s (%s)", ontology_status, settings.domain_id) + + # NAMS only accepts messages addressed to conversation ids IT minted + # at create time — client-chosen session strings 404. Create each + # message channel lazily and target the server-assigned id. + _channel_ids: dict[str, str | None] = {} + + async def _message_channel(session_hint: str) -> str | None: + if session_hint not in _channel_ids: + try: + conv = await client.short_term.create_conversation( + session_id=session_hint + ) + _channel_ids[session_hint] = str(getattr(conv, "id", "") or "") or None + except Exception as exc: # noqa: BLE001 + _append_deadletter({ + "ts": _now_utc_iso(), "kind": "conversation", + "name": session_hint, "error": str(exc), + }) + _channel_ids[session_hint] = None + return _channel_ids[session_hint] + # Entities + bodies entities = data.get("entities", {}) ent_idx = 0 @@ -258,11 +333,16 @@ async def _ingest_via_nams(data: dict[str, Any], body_fields: dict[str, str]) -> if body is None: continue try: + channel = await _message_channel("bodies-import") + if channel is None: + raise RuntimeError("bodies conversation unavailable") + # role must be user/assistant/system — NAMS rejects + # custom roles; metadata kind marks extraction fuel. await client.short_term.add_message( - session_id="bodies-import", - role="document", + session_id=channel, + role="user", content=body, - metadata={"entity_name": name, "entity_label": label}, + metadata={"kind": "entity-body", "entity_name": name, "entity_label": label}, ) counts["bodies"] += 1 except Exception as exc: # noqa: BLE001 @@ -290,11 +370,15 @@ async def _ingest_via_nams(data: dict[str, Any], body_fields: dict[str, str]) -> await client.long_term.add_entity( name=title, entity_type="OBJECT", description=description, ) + channel = await _message_channel("docs-import") + if channel is None: + raise RuntimeError("docs conversation unavailable") await client.short_term.add_message( - session_id="docs-import", - role="document", + session_id=channel, + role="user", content=content, metadata={ + "kind": "document", "title": title, "template_id": doc.get("template_id", ""), "template_name": doc.get("template_name", ""), @@ -363,7 +447,9 @@ async def _ingest_via_bolt(data: dict[str, Any], body_fields: dict[str, str]) -> await driver.close() raise RuntimeError(f"Failed to connect to Neo4j at {settings.neo4j_uri}: {exc}") from exc - async with driver, driver.session() as session: + # database=None defers to the server default ("neo4j" unless reconfigured); + # NEO4J_DATABASE overrides it for instances whose database has another name. + async with driver, driver.session(database=settings.neo4j_database or None) as session: # Entities — UNWIND batched by label for label, items in data.get("entities", {}).items(): if not items: diff --git a/src/create_context_graph/templates/backend/shared/generate_data.py.j2 b/src/create_context_graph/templates/backend/shared/generate_data.py.j2 index 87ab53d..b11cfbb 100644 --- a/src/create_context_graph/templates/backend/shared/generate_data.py.j2 +++ b/src/create_context_graph/templates/backend/shared/generate_data.py.j2 @@ -13,19 +13,30 @@ from app.context_graph_client import connect_neo4j, close_neo4j, execute_cypher DATA_DIR = Path(__file__).parent.parent.parent / "data" +def _split_cypher_statements(script: str) -> list[str]: + """Split a Cypher script into executable statements. + + Comment lines are removed BEFORE splitting on ``;`` — otherwise a + semicolon inside a comment splits mid-comment (executing garbage), and a + statement preceded by a comment header gets skipped entirely. + """ + code_lines = [ + line for line in script.splitlines() if not line.strip().startswith("//") + ] + return [s.strip() for s in "\n".join(code_lines).split(";") if s.strip()] + + async def apply_schema(): """Apply the Cypher schema constraints and indexes.""" schema_path = Path(__file__).parent.parent.parent / "cypher" / "schema.cypher" if schema_path.exists(): schema = schema_path.read_text() - for statement in schema.split(";"): - stmt = statement.strip() - if stmt and not stmt.startswith("//"): - try: - await execute_cypher(stmt) - print(f" Applied: {stmt[:60]}...") - except Exception as e: - print(f" Warning: {e}") + for stmt in _split_cypher_statements(schema): + try: + await execute_cypher(stmt) + print(f" Applied: {stmt[:60]}...") + except Exception as e: + print(f" Warning: {e}") def _batch(items: list, size: int = 500) -> list[list]: diff --git a/src/create_context_graph/templates/backend/shared/memory.py.j2 b/src/create_context_graph/templates/backend/shared/memory.py.j2 index 95d1da5..ef5a8c7 100644 --- a/src/create_context_graph/templates/backend/shared/memory.py.j2 +++ b/src/create_context_graph/templates/backend/shared/memory.py.j2 @@ -14,8 +14,10 @@ Native adapters are resolved first; everything else routes through LiteLLM. from __future__ import annotations +import json import logging import uuid +from pathlib import Path from app.config import settings @@ -26,6 +28,14 @@ _client = None # MemoryClient | None _error_category: str | None = None # "auth" | "rate_limit" | "network" | "config" | "unknown" _error_detail: str | None = None # short human-readable detail +# NAMS only: app session id → server-assigned conversation id. The NAMS +# service accepts messages solely for conversation ids IT minted at create +# time — posting to a client-chosen id 404s with "conversation not found" +# (and neo4j-agent-memory <=0.5.0 posts the client id straight through). +# Process-local by design: after a backend restart an old session id gets a +# fresh conversation, so context resets instead of every write failing. +_nams_conversation_ids: dict[str, str] = {} + # Bucketed error messages shown to the user when NAMS init fails. Keys match # _error_category values produced by _classify_memory_error(). @@ -165,6 +175,74 @@ def _build_memory_settings(): ) +def _load_local_ontology_document() -> dict | None: + """Load the scaffold's NAMS ontology document (written at generation time).""" + path = Path(__file__).parent / "ontology_document.json" + try: + return json.loads(path.read_text()) + except Exception: # noqa: BLE001 — missing/corrupt file just disables create + return None + + +async def _ensure_nams_ontology() -> None: + """Bind the NAMS workspace to this app's domain ontology (best-effort). + + NAMS auto-binds every workspace to the generic ``nams-default`` ontology + until an explicit one is activated; the service pre-registers an ontology + for each bundled domain, and every stored entity is stamped with the + active ontology version. Order: already active → no-op; catalog match by + domain id → activate its latest version; otherwise create from the + scaffold's ``ontology_document.json`` (custom domains) and activate. + + Never raises — memory works against the default ontology, just without + domain-shaped extraction. + """ + if settings.memory_backend != "nams" or _client is None: + return + try: + active = await _client.ontology.get_active() + active_domain = getattr( + getattr(getattr(active, "document", None), "domain", None), "id", None + ) + if active_domain == settings.domain_id: + return + + summaries = await _client.ontology.list() + match = next( + (s for s in summaries if getattr(s, "name", None) == settings.domain_id), + None, + ) + if match is not None: + full = await _client.ontology.get(ontology_id=match.id) + versions = getattr(full, "versions", None) or [] + if versions: + latest = max(versions, key=lambda v: getattr(v, "revision", 0) or 0) + await _client.ontology.activate(version_id=latest.id) + logger.info( + "Activated NAMS ontology '%s' (was '%s')", + settings.domain_id, active_domain, + ) + return + + doc = _load_local_ontology_document() + if doc is not None: + created = await _client.ontology.create( + name=settings.domain_id, schema=doc + ) + await _client.ontology.activate(version_id=created.id) + logger.info( + "Created + activated NAMS ontology '%s' (was '%s')", + settings.domain_id, active_domain, + ) + return + logger.warning( + "No NAMS ontology found for domain '%s' — staying on '%s'", + settings.domain_id, active_domain, + ) + except Exception as e: # noqa: BLE001 — ontology is an enhancement, not a gate + logger.warning("NAMS ontology activation skipped: %s", e) + + async def connect_memory() -> None: """Initialize MemoryIntegration. No-ops if the library is unavailable.""" global _memory, _client, _error_category, _error_detail @@ -190,6 +268,10 @@ async def connect_memory() -> None: ms = _build_memory_settings() _client = MemoryClient(ms) await _client.connect() + # Bind the workspace to this app's domain ontology (NAMS only; no-op + # on bolt) before any writes so data is stamped with — and extraction + # speaks — the domain vocabulary instead of nams-default. + await _ensure_nams_ontology() _memory = MemoryIntegration( client=_client, session_strategy=strategy_map.get( @@ -233,6 +315,7 @@ async def connect_memory() -> None: async def close_memory() -> None: """Shut down MemoryIntegration gracefully.""" global _memory, _client, _error_category, _error_detail + _nams_conversation_ids.clear() if _memory is not None: try: await _memory.close() @@ -279,6 +362,28 @@ def get_error_detail() -> str | None: return _error_detail +async def _resolve_nams_conversation(session_id: str) -> str: + """Return the NAMS conversation id backing ``session_id`` (NAMS only). + + Creates the conversation on first use and caches the server-assigned id — + the only id the message endpoints accept. Non-NAMS backends (and any + create failure) pass the session id through unchanged. + """ + if settings.memory_backend != "nams" or _client is None: + return session_id + mapped = _nams_conversation_ids.get(session_id) + if mapped: + return mapped + try: + conv = await _client.short_term.create_conversation(session_id=session_id) + conv_id = str(getattr(conv, "id", "") or session_id) + except Exception as e: + logger.warning("NAMS create_conversation failed for %s: %s", session_id, e) + return session_id + _nams_conversation_ids[session_id] = conv_id + return conv_id + + async def store_message(session_id: str, role: str, content: str) -> dict | None: """Store a message and return extraction results (entities, preferences). @@ -289,6 +394,8 @@ async def store_message(session_id: str, role: str, content: str) -> dict | None Records failures into the same ``_error_category``/``_error_detail`` state ``connect_memory()`` uses, so ``get_error_category()``/``get_error_message()`` reflect live write failures too — not just failures at startup connect time. + ``MemoryIntegration`` swallows its own errors into ``{"error": ...}`` return + values, so those are treated as failures rather than successes. """ global _error_category, _error_detail if _memory is None: @@ -298,7 +405,17 @@ async def store_message(session_id: str, role: str, content: str) -> dict | None except ImportError: NotSupportedError = Exception # type: ignore[assignment, misc] try: - result = await _memory.store_message(role, content, session_id=session_id) + target_session = await _resolve_nams_conversation(session_id) + result = await _memory.store_message(role, content, session_id=target_session) + if isinstance(result, dict) and result.get("error"): + _error_category, _error_detail = _classify_memory_error( + RuntimeError(str(result["error"])) + ) + logger.warning( + "Failed to store message [%s/%s]: %s", + _error_category, _error_detail, result["error"], + ) + return None _error_category = None _error_detail = None return result @@ -323,8 +440,9 @@ async def get_context( if _memory is None: return empty try: + target_session = await _resolve_nams_conversation(session_id) return await _memory.get_context( - session_id=session_id, query=query, max_items=max_items + session_id=target_session, query=query, max_items=max_items ) except Exception as e: logger.warning("Failed to get context: %s", e) diff --git a/src/create_context_graph/templates/backend/shared/memory_adapter.py.j2 b/src/create_context_graph/templates/backend/shared/memory_adapter.py.j2 index d5067f8..cf58f90 100644 --- a/src/create_context_graph/templates/backend/shared/memory_adapter.py.j2 +++ b/src/create_context_graph/templates/backend/shared/memory_adapter.py.j2 @@ -113,7 +113,8 @@ async def ingest_fixtures_nams(fixture_data: dict[str, Any], domain_id: str) -> ``add_relationship``). * Documents → dual-tracked: ``add_entity(name=title, type=OBJECT)`` so the document is a queryable long-term entity AND - ``short_term.add_message(role="document")`` so the NAMS extractor + ``short_term.add_message`` (role="user", metadata kind="document", + addressed to a server-created conversation) so the NAMS extractor sees the prose. * Decision traces use the reasoning REST API. """ @@ -149,8 +150,15 @@ async def ingest_fixtures_nams(fixture_data: dict[str, Any], domain_id: str) -> print(f" [warn] Entity {name}: {e}") print(f" [1/3] Ingested {entity_count} entities ({edges_encoded} with ccg-edges)") - # Documents → dual-tracked - doc_session = f"docs-{domain_id}" + # Documents → dual-tracked. NAMS only accepts messages addressed to a + # conversation id IT minted — create the channel once and target that id; + # role must be user/assistant/system (custom roles are rejected). + doc_channel = None + try: + conv = await client.short_term.create_conversation(session_id=f"docs-{domain_id}") + doc_channel = str(getattr(conv, "id", "") or "") or None + except Exception as e: + print(f" [warn] docs conversation unavailable ({e}) — storing entities only") doc_count = 0 for doc in fixture_data.get("documents", []): title = doc.get("title", "") @@ -166,17 +174,19 @@ async def ingest_fixtures_nams(fixture_data: dict[str, Any], domain_id: str) -> await client.long_term.add_entity( name=title, entity_type="OBJECT", description=description, ) - await client.short_term.add_message( - session_id=doc_session, - role="document", - content=content, - metadata={ - "title": title, - "template_id": doc.get("template_id", ""), - "template_name": doc.get("template_name", ""), - "domain": domain_id, - }, - ) + if doc_channel is not None: + await client.short_term.add_message( + session_id=doc_channel, + role="user", + content=content, + metadata={ + "kind": "document", + "title": title, + "template_id": doc.get("template_id", ""), + "template_name": doc.get("template_name", ""), + "domain": domain_id, + }, + ) doc_count += 1 except Exception as e: print(f" [warn] Document {title}: {e}") @@ -212,13 +222,32 @@ async def ingest_fixtures_nams(fixture_data: dict[str, Any], domain_id: str) -> # --------------------------------------------------------------------------- # Documents — on NAMS, stored as long-term Document entities (queryable). -# The same content is mirrored into short_term as role="document" messages so +# The same content is mirrored into short_term as kind="document" messages so # the NAMS extractor can mine the prose, but the entity is the source of # truth for the document browser (matches the bolt graph shape). # --------------------------------------------------------------------------- _DOCUMENT_QUERY_HINT = "Document" +# Written into every document description by the seed/import pipelines; the +# service-independent signal that an entity is a document. +_DOCUMENT_MARKER = "_pole_type: OBJECT_" + + +def _document_record_from_fields(name: str, description: str) -> dict[str, Any] | None: + """Build the doc-browser record from raw name/description fields.""" + content = description + if CCG_EDGES_OPEN in content: + content = content.split(CCG_EDGES_OPEN, 1)[0].rstrip() + if "_pole_type:" in content: + content = content.rsplit("_pole_type:", 1)[0].rstrip() + if not content.strip(): + return None + return { + "title": name, + "content": content, + "preview": content[:200], + } def _document_record_from_entity(entity: Any) -> dict[str, Any] | None: @@ -250,31 +279,59 @@ async def list_documents_nams( client = get_client() if client is None: return [] + # Preferred: enumerate via the cypher API using the _pole_type marker — + # the live NAMS service coerces unknown entity_type values (like OBJECT) + # to "custom", so a server-side type filter finds nothing. The marker is + # this scaffold's own write contract and survives that coercion. try: - # Push the OBJECT filter to the server so the (skip + limit + 50) - # buffer isn't eaten by unrelated PERSON/ORGANIZATION/LOCATION/EVENT - # entities — that used to cause documents to be silently dropped on - # busy NAMS instances. - entities = await client.long_term.search_entities( - query=_DOCUMENT_QUERY_HINT, entity_type="OBJECT", limit=skip + limit + 50, + rows = await client.query.cypher( + "MATCH (n) WHERE n.description CONTAINS $marker " + "RETURN n.name AS name, n.description AS description " + "ORDER BY n.name SKIP $skip LIMIT $limit", + {"marker": _DOCUMENT_MARKER, "skip": skip, "limit": limit}, ) + docs = [] + for row in rows or []: + if not isinstance(row, dict) or not row.get("name"): + continue + rec = _document_record_from_fields(row["name"], row.get("description") or "") + if rec is None: + continue + rec["template_id"] = "" + rec["template_name"] = "" + rec["mentioned_entities"] = [] + docs.append(rec) + return docs except Exception as e: - logger.info("list_documents_nams: search_entities failed: %s", e) - return [] + logger.info("list_documents_nams: cypher path failed (%s) — using search", e) - docs: list[dict[str, Any]] = [] + # Fallback: search API. Try the server-side OBJECT filter first (honored + # by older services), then unfiltered with client-side marker matching. + entities = [] + for kwargs in ({"entity_type": "OBJECT"}, {}): + try: + entities = await client.long_term.search_entities( + query=_DOCUMENT_QUERY_HINT, limit=skip + limit + 50, **kwargs, + ) + except Exception as e: + logger.info("list_documents_nams: search_entities failed: %s", e) + entities = [] + if entities: + break + + docs = [] for ent in entities: ent_type = getattr(ent, "entity_type", None) or getattr(ent, "type", None) - # NAMS doesn't have a Document label; we stored docs as type=OBJECT - # with the doc title as the entity name. Filter by description shape - # — anything without prose is not a document. + description = getattr(ent, "description", "") or "" + # Accept anything carrying our document marker; keep the legacy + # type-based acceptance for pre-marker data. + is_marked = _DOCUMENT_MARKER in description + is_legacy_typed = bool(ent_type) and ent_type.upper() in {"OBJECT", "DOCUMENT"} + if not (is_marked or is_legacy_typed): + continue rec = _document_record_from_entity(ent) if rec is None: continue - # Belt-and-suspenders client-side filter — server already restricted - # to OBJECT, but legacy data may use the older DOCUMENT label. - if ent_type and ent_type.upper() not in {"OBJECT", "DOCUMENT"}: - continue rec["template_id"] = "" rec["template_name"] = "" rec["mentioned_entities"] = [] @@ -368,6 +425,59 @@ async def expand_node_nams(element_id: str) -> dict[str, Any]: client = get_client() if client is None: return {"nodes": [], "relationships": []} + + # Preferred: the cypher API. neo4j-agent-memory 0.5.x has no + # ``long_term.get_entity(id)`` — id-addressed lookups only exist through + # cypher, which also returns the server-side relationships (e.g. the + # SAME_AS edges NAMS entity resolution creates). + try: + rows = await client.query.cypher( + "MATCH (n) WHERE n.id = $id " + "OPTIONAL MATCH (n)-[r]-(m) " + "RETURN n.id AS id, n.name AS name, n.type AS type, " + "n.description AS description, type(r) AS rel_type, " + "m.id AS other_id, m.name AS other_name, m.type AS other_type, " + "m.description AS other_description, " + "CASE WHEN r IS NULL THEN NULL WHEN startNode(r).id = n.id THEN true ELSE false END AS outgoing", + {"id": element_id}, + ) + if rows: + nodes: dict[str, dict[str, Any]] = {} + rels: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict) or not row.get("id"): + continue + nodes.setdefault(str(row["id"]), { + "elementId": str(row["id"]), + "labels": [row.get("type") or "Entity"], + "name": row.get("name") or "", + "description": row.get("description") or "", + }) + other_id = row.get("other_id") + if not other_id or not row.get("rel_type"): + continue + nodes.setdefault(str(other_id), { + "elementId": str(other_id), + "labels": [row.get("other_type") or "Entity"], + "name": row.get("other_name") or "", + "description": row.get("other_description") or "", + }) + start, end = ( + (str(row["id"]), str(other_id)) + if row.get("outgoing") in (True, None) + else (str(other_id), str(row["id"])) + ) + rels.append({ + "elementId": f"{start}-{row['rel_type']}-{end}", + "type": row["rel_type"], + "startNodeElementId": start, + "endNodeElementId": end, + }) + return {"nodes": list(nodes.values()), "relationships": rels} + except Exception as e: + logger.info("expand_node_nams: cypher path failed (%s) — using REST", e) + + # Fallback: REST get_entity with inlined relationships (older lib/service). try: entity = await client.long_term.get_entity(element_id) except Exception as e: @@ -417,16 +527,28 @@ async def schema_visualization_nams() -> dict[str, Any]: client = get_client() if client is None: return {"nodes": [], "relationships": []} + by_type: dict[str, int] = {} + # Preferred: aggregate via the cypher API. The search API cannot list + # everything — the live service rejects an empty query outright. try: - entities = await client.long_term.search_entities(query="", limit=1000) + rows = await client.query.cypher( + "MATCH (n) WHERE n.type IS NOT NULL " + "RETURN n.type AS type, count(n) AS count ORDER BY count DESC", + {}, + ) + for row in rows or []: + if isinstance(row, dict) and row.get("type"): + by_type[str(row["type"])] = int(row.get("count", 0)) except Exception as e: - logger.info("schema_visualization_nams: search_entities failed: %s", e) - return {"nodes": [], "relationships": []} - - by_type: dict[str, int] = {} - for ent in entities: - t = getattr(ent, "type", "Entity") or "Entity" - by_type[t] = by_type.get(t, 0) + 1 + logger.info("schema_visualization_nams: cypher path failed (%s) — using search", e) + try: + entities = await client.long_term.search_entities(query="entity", limit=1000) + except Exception as e2: + logger.info("schema_visualization_nams: search_entities failed: %s", e2) + return {"nodes": [], "relationships": []} + for ent in entities: + t = getattr(ent, "type", "Entity") or "Entity" + by_type[t] = by_type.get(t, 0) + 1 nodes = [ { @@ -456,24 +578,47 @@ async def get_entity_detail_nams(name: str) -> dict[str, Any] | None: if entity is None: return None entity_id = getattr(entity, "id", None) or getattr(entity, "entity_id", None) - inlined = getattr(entity, "relationships", None) or [] connections: list[dict[str, Any]] = [] - for rel in inlined: - target_id = getattr(rel, "target_id", None) or getattr(rel, "target", None) - if target_id is None: - continue + # Preferred: cypher — id-addressed neighbor lookup doesn't exist on the + # 0.5.x REST client, and this also surfaces server-created edges. + if entity_id: try: - target = await client.long_term.get_entity(target_id) - except Exception: - continue - connections.append( - { - "name": getattr(target, "name", "") or "", - "labels": [getattr(target, "type", "Entity") or "Entity"], - "relationship": getattr(rel, "type", "RELATED_TO"), - "direction": "outgoing", - } - ) + rows = await client.query.cypher( + "MATCH (n) WHERE n.id = $id MATCH (n)-[r]-(m) " + "RETURN type(r) AS rel_type, m.name AS name, m.type AS type, " + "CASE WHEN startNode(r).id = n.id THEN 'outgoing' ELSE 'incoming' END AS direction", + {"id": str(entity_id)}, + ) + for row in rows or []: + if not isinstance(row, dict) or not row.get("name"): + continue + connections.append({ + "name": row["name"], + "labels": [row.get("type") or "Entity"], + "relationship": row.get("rel_type") or "RELATED_TO", + "direction": row.get("direction") or "outgoing", + }) + except Exception as e: + logger.info("get_entity_detail_nams: cypher connections failed: %s", e) + if not connections: + # Fallback: inlined relationships from the REST response (older lib). + inlined = getattr(entity, "relationships", None) or [] + for rel in inlined: + target_id = getattr(rel, "target_id", None) or getattr(rel, "target", None) + if target_id is None: + continue + try: + target = await client.long_term.get_entity(target_id) + except Exception: + continue + connections.append( + { + "name": getattr(target, "name", "") or "", + "labels": [getattr(target, "type", "Entity") or "Entity"], + "relationship": getattr(rel, "type", "RELATED_TO"), + "direction": "outgoing", + } + ) return { "entity": { "name": getattr(entity, "name", "") or "", diff --git a/src/create_context_graph/templates/backend/tests/test_routes.py.j2 b/src/create_context_graph/templates/backend/tests/test_routes.py.j2 index e47e051..b53aba6 100644 --- a/src/create_context_graph/templates/backend/tests/test_routes.py.j2 +++ b/src/create_context_graph/templates/backend/tests/test_routes.py.j2 @@ -54,3 +54,49 @@ def test_scenarios(): assert "scenarios" in data assert isinstance(data["scenarios"], list) {% endraw %} +{% if is_nams %}{% raw %} + +def test_health_reports_nams_backend(): + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["memory_backend"] == "nams" + assert "nams" in data + + +def test_health_degrades_when_nams_client_missing(): + """/health must report degraded (with guidance) when the NAMS client + never connected — not crash, and not claim "ok".""" + with patch("app.main.get_memory_status", return_value=False), \ + patch("app.main.get_error_category", return_value="auth"): + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "degraded" + assert data["nams_error"] == "auth" + assert "nams_dashboard" in data +{% endraw %}{% else %}{% raw %} + +def test_health_reports_bolt_backend_with_memory_field(): + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["memory_backend"] == "bolt" + assert data["neo4j"] is True + assert data["memory"] is True + + +def test_health_surfaces_live_memory_write_failures(): + """A store_message() failure (e.g. wrong NEO4J_DATABASE) must flip + /health to degraded with the classified error — not stay "ok" while + every memory write silently fails.""" + with patch("app.main.get_error_category", return_value="network"), \ + patch("app.main.get_error_detail", return_value="ConnectionError"): + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "degraded" + assert data["memory"] is False + assert data["memory_error"] == "network" + assert data["memory_error_detail"] == "ConnectionError" +{% endraw %}{% endif %} diff --git a/src/create_context_graph/templates/base/Makefile.j2 b/src/create_context_graph/templates/base/Makefile.j2 index e49a427..44320de 100644 --- a/src/create_context_graph/templates/base/Makefile.j2 +++ b/src/create_context_graph/templates/base/Makefile.j2 @@ -51,23 +51,11 @@ seed: cd backend && uv run python scripts/generate_data.py {% if is_nams %} -# Reset NAMS memory store (per-entity REST deletes — slow; use --self-hosted for fast Cypher reset) +# NAMS REST (and neo4j-agent-memory <=0.5.x) expose no entity delete API, +# so there is nothing a client-side reset can remove. reset: - @echo "Resetting NAMS memory store via REST (this may take a while)..." - cd backend && uv run python -c "import asyncio; from app.memory import connect_memory, close_memory, get_client; \ -async def _reset(): \ - await connect_memory(); \ - c = get_client(); \ - if c is None: print('NAMS client not connected'); return; \ - entities = await c.long_term.search_entities(query='', limit=1000); \ - for e in entities: \ - eid = getattr(e, 'id', None) or getattr(e, 'entity_id', None); \ - if eid: \ - try: await c.long_term.delete_entity(eid) \ - except Exception: pass; \ - print(f'Removed {len(entities)} entities'); \ - await close_memory(); \ -asyncio.run(_reset())" + @echo "NAMS exposes no delete API (neo4j-agent-memory 0.5.x) — reset is not available from the CLI." + @echo "Manage stored data at https://memory.neo4jlabs.com, or use a --self-hosted scaffold for full control." # Test NAMS connection test-connection: diff --git a/src/create_context_graph/templates/base/README.md.j2 b/src/create_context_graph/templates/base/README.md.j2 index 71771a3..53c6fed 100644 --- a/src/create_context_graph/templates/base/README.md.j2 +++ b/src/create_context_graph/templates/base/README.md.j2 @@ -224,6 +224,7 @@ cp .env.example .env | `NEO4J_URI` | Neo4j connection URI | | `NEO4J_USERNAME` | Neo4j username | | `NEO4J_PASSWORD` | Neo4j password | +| `NEO4J_DATABASE` | Database name — blank uses the driver default (`neo4j`); set for instances whose database has another name (common on Aura API/CLI-provisioned instances) | | `ANTHROPIC_API_KEY` | Anthropic API key for the AI agent | | `OPENAI_API_KEY` | OpenAI API key (for embeddings) | | `SESSION_STRATEGY` | Memory session strategy (per_conversation, per_day, persistent) | @@ -237,6 +238,14 @@ cp .env.example .env **"Neo4j is unavailable" on all endpoints** - Verify your `.env` file has correct `NEO4J_URI`, `NEO4J_USERNAME`, and `NEO4J_PASSWORD` - Run `make test-connection` to validate credentials + +**`/health` shows `"status": "degraded"` with a `memory_error` while chat still works** +- The Neo4j connection is fine but memory writes are failing — most often the + database name: if your instance's database isn't literally named `neo4j` + (common on Aura instances provisioned via the Aura API/CLI), set + `NEO4J_DATABASE` in `.env` and restart +- `memory_error` is the classified category (`auth`/`rate_limit`/`network`/`config`/`unknown`); + `memory_error_detail` carries the short technical detail {% if neo4j_type == 'aura' %} - Ensure your Aura instance is running and the URI starts with `neo4j+s://` {% elif neo4j_type == 'docker' %} diff --git a/src/create_context_graph/templates/base/dot_env_example.j2 b/src/create_context_graph/templates/base/dot_env_example.j2 index e261aa4..9de30d1 100644 --- a/src/create_context_graph/templates/base/dot_env_example.j2 +++ b/src/create_context_graph/templates/base/dot_env_example.j2 @@ -18,6 +18,10 @@ MEMORY_API_KEY=your-nams-api-key-here NEO4J_URI=neo4j://localhost:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD=your-password-here +# Database name. Leave blank to use the SDK default ("neo4j") — override this +# if your instance's database has a different name (e.g. Aura instances +# provisioned via the Aura API/CLI often name it after the instance id). +NEO4J_DATABASE= {% endif %} # Memory layer LLM + embedding provider (LiteLLM-style strings, optional). diff --git a/src/create_context_graph/templates/frontend/e2e/app.spec.ts.j2 b/src/create_context_graph/templates/frontend/e2e/app.spec.ts.j2 index e45246d..1341a8a 100644 --- a/src/create_context_graph/templates/frontend/e2e/app.spec.ts.j2 +++ b/src/create_context_graph/templates/frontend/e2e/app.spec.ts.j2 @@ -135,7 +135,7 @@ test.describe("{{ domain.name }} Context Graph", () => { // Send a prompt that should trigger tool calls const input = page.getByPlaceholder(/ask about/i); - await input.fill({{ (demo_scenarios[0].prompts[0] if demo_scenarios else 'Show me the data in the graph') | tojson }}); + await input.fill({{ (demo_scenarios[0].prompts[0] if demo_scenarios and demo_scenarios[0].prompts else 'Show me the data in the graph') | tojson }}); await page.getByRole("button", { name: /send/i }).click(); // Wait for at least one tool call badge to appear @@ -155,7 +155,7 @@ test.describe("{{ domain.name }} Context Graph", () => { // Send a query that should return graph data const input = page.getByPlaceholder(/ask about/i); - await input.fill({{ (demo_scenarios[0].prompts[0] if demo_scenarios else 'Show me the data in the graph') | tojson }}); + await input.fill({{ (demo_scenarios[0].prompts[0] if demo_scenarios and demo_scenarios[0].prompts else 'Show me the data in the graph') | tojson }}); await page.getByRole("button", { name: /send/i }).click(); // Wait for the graph to switch from schema to data view diff --git a/tests/__pycache__/__init__.cpython-311.pyc b/tests/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 1521c00..0000000 Binary files a/tests/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/tests/__pycache__/conftest.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-311-pytest-9.0.2.pyc deleted file mode 100644 index 3203a30..0000000 Binary files a/tests/__pycache__/conftest.cpython-311-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_cli.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_cli.cpython-311-pytest-9.0.2.pyc deleted file mode 100644 index 28d6b9b..0000000 Binary files a/tests/__pycache__/test_cli.cpython-311-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_config.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_config.cpython-311-pytest-9.0.2.pyc deleted file mode 100644 index c44a839..0000000 Binary files a/tests/__pycache__/test_config.cpython-311-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_generator.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_generator.cpython-311-pytest-9.0.2.pyc deleted file mode 100644 index 7cd1ce6..0000000 Binary files a/tests/__pycache__/test_generator.cpython-311-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_ontology.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_ontology.cpython-311-pytest-9.0.2.pyc deleted file mode 100644 index c7ac202..0000000 Binary files a/tests/__pycache__/test_ontology.cpython-311-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_renderer.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_renderer.cpython-311-pytest-9.0.2.pyc deleted file mode 100644 index c440038..0000000 Binary files a/tests/__pycache__/test_renderer.cpython-311-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/conftest.py b/tests/conftest.py index ddd865a..8b02696 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,6 +57,38 @@ def invoke(self, cli, args=None, *a, **kw): # type: ignore[override] return super().invoke(cli, args, *a, **kw) +@pytest.fixture(scope="session") +def _isolated_custom_domains_dir(tmp_path_factory) -> Path: + """A session-scoped path for custom domains that never exists on disk.""" + return tmp_path_factory.mktemp("isolated-home") / "custom-domains" + + +@pytest.fixture(autouse=True) +def _isolate_custom_domains(_isolated_custom_domains_dir, monkeypatch): + """Keep the suite hermetic against ~/.create-context-graph/custom-domains/. + + ``list_available_domains()`` and ``load_domain()`` both scan the user-local + custom domains directory. Without this fixture, any custom domain a + contributor has saved on their machine leaks into every test that iterates + the available domains (ontology validation, fixture cross-checks, the + domain x framework matrix), producing failures that don't reproduce in CI. + + Tests that need a custom-domains directory patch ``_get_custom_domains_path`` + themselves (``unittest.mock.patch`` layers cleanly over this fixture). + """ + from create_context_graph import custom_domain as custom_domain_mod + from create_context_graph import ontology as ontology_mod + + # Both modules hold their own reference (custom_domain imports it by name). + monkeypatch.setattr( + ontology_mod, "_get_custom_domains_path", lambda: _isolated_custom_domains_dir + ) + monkeypatch.setattr( + custom_domain_mod, "_get_custom_domains_path", lambda: _isolated_custom_domains_dir + ) + yield _isolated_custom_domains_dir + + @pytest.fixture def runner(): """Pre-bolt-default CLI runner. Auto-adds ``--self-hosted`` to invocations. diff --git a/tests/test_bolt_ingest_parity.py b/tests/test_bolt_ingest_parity.py index d52de13..eecbbf8 100644 --- a/tests/test_bolt_ingest_parity.py +++ b/tests/test_bolt_ingest_parity.py @@ -126,6 +126,7 @@ def __init__(self): self.session_obj = _RecordingSession() self.closed = False self.verified = False + self.session_kwargs: list[dict[str, Any]] = [] async def verify_connectivity(self): self.verified = True @@ -139,7 +140,8 @@ async def __aenter__(self): async def __aexit__(self, *_): await self.close() - def session(self): + def session(self, **kwargs): + self.session_kwargs.append(dict(kwargs)) outer = self class _Ctx: @@ -178,6 +180,7 @@ def _exec_scaffold_template(driver: _RecordingDriver) -> dict[str, Any]: neo4j_uri="neo4j://test:7687", neo4j_username="neo4j", neo4j_password="testpass", + neo4j_database="clinical-db", ) fake_config_mod = ModuleType("app.config") fake_config_mod.settings = fake_settings @@ -254,6 +257,17 @@ def test_async_context_lifecycle(bolt_run): assert driver.closed, "driver was not closed (async with driver: should auto-close)" +def test_session_targets_configured_database(bolt_run): + """v0.14.0: the bolt session must honor ``NEO4J_DATABASE``. Without the + ``database=`` kwarg, ``make import`` writes connector data into the server + default database while the app reads from the configured one — silently + producing an empty graph on Aura instances whose database isn't "neo4j".""" + driver, _ = bolt_run + assert driver.session_kwargs, "no session was opened" + for kwargs in driver.session_kwargs: + assert kwargs.get("database") == "clinical-db" + + def test_counts(bolt_run): _, counts = bolt_run # 3 patients + hospital MERGE batches → entities counts each batch's len diff --git a/tests/test_cli.py b/tests/test_cli.py index 2c06da7..a0fa3be 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1009,3 +1009,240 @@ def test_multiple_paths_and_exclude(self, runner, tmp_path): "--output-dir", str(out), ]) assert result.exit_code == 0, result.output + + +class TestNeo4jDatabaseFlag: + """v0.14.0: --neo4j-database / NEO4J_DATABASE threading (PR #60).""" + + def test_database_flag_lands_in_env(self, runner, tmp_path): + out = tmp_path / "db-app" + result = runner.invoke(main, [ + "db-app", + "--domain", "financial-services", + "--framework", "pydanticai", + "--self-hosted", + "--neo4j-database", "clinical-db", + "--output-dir", str(out), + ]) + assert result.exit_code == 0, result.output + env = (out / ".env").read_text() + assert "NEO4J_DATABASE=clinical-db" in env + + def test_database_defaults_to_blank(self, runner, tmp_path): + """Blank defers to the SDK default ("neo4j") — the line must still be + present so users discover the knob.""" + out = tmp_path / "db-default-app" + result = runner.invoke(main, [ + "db-default-app", + "--domain", "financial-services", + "--framework", "pydanticai", + "--self-hosted", + "--output-dir", str(out), + ]) + assert result.exit_code == 0, result.output + env = (out / ".env").read_text() + assert "NEO4J_DATABASE=\n" in env + example = (out / ".env.example").read_text() + assert "NEO4J_DATABASE=" in example + + def test_aura_env_database_is_imported(self, runner, tmp_path): + """NEO4J_DATABASE in an imported Aura .env file must not be silently + discarded (the original bug motivating PR #60).""" + aura_env = tmp_path / "aura.env" + aura_env.write_text( + 'NEO4J_URI=neo4j+s://abc123.databases.neo4j.io\n' + 'NEO4J_USERNAME=neo4j\n' + 'NEO4J_PASSWORD=super-secret\n' + 'NEO4J_DATABASE=instance-4f9a\n' + ) + out = tmp_path / "aura-db-app" + result = runner.invoke(main, [ + "aura-db-app", + "--domain", "financial-services", + "--framework", "pydanticai", + "--neo4j-aura-env", str(aura_env), + "--output-dir", str(out), + ]) + assert result.exit_code == 0, result.output + env = (out / ".env").read_text() + assert "NEO4J_DATABASE=instance-4f9a" in env + + def test_explicit_flag_wins_over_aura_env(self, runner, tmp_path): + aura_env = tmp_path / "aura.env" + aura_env.write_text( + 'NEO4J_URI=neo4j+s://abc123.databases.neo4j.io\n' + 'NEO4J_PASSWORD=super-secret\n' + 'NEO4J_DATABASE=from-file\n' + ) + out = tmp_path / "aura-override-app" + result = runner.invoke(main, [ + "aura-override-app", + "--domain", "financial-services", + "--framework", "pydanticai", + "--neo4j-aura-env", str(aura_env), + "--neo4j-database", "from-flag", + "--output-dir", str(out), + ]) + assert result.exit_code == 0, result.output + env = (out / ".env").read_text() + assert "NEO4J_DATABASE=from-flag" in env + assert "from-file" not in env + + def test_dry_run_shows_database(self, runner, tmp_path): + result = runner.invoke(main, [ + "dry-db-app", + "--domain", "financial-services", + "--framework", "pydanticai", + "--self-hosted", + "--neo4j-database", "clinical-db", + "--dry-run", + "--output-dir", str(tmp_path / "never-created"), + ]) + assert result.exit_code == 0, result.output + assert "database=clinical-db" in result.output + assert not (tmp_path / "never-created").exists() + + +class TestOntologyFileFlag: + """v0.14.0: --ontology-file scaffolds from a hand-written YAML (PR #58).""" + + @pytest.fixture + def ontology_file(self, tmp_path): + from tests.test_custom_domain import VALID_DOMAIN_YAML + + path = tmp_path / "my-domain.yaml" + path.write_text(VALID_DOMAIN_YAML) + return path + + def test_scaffold_from_ontology_file(self, runner, tmp_path, ontology_file): + out = tmp_path / "ont-app" + result = runner.invoke(main, [ + "ont-app", + "--ontology-file", str(ontology_file), + "--framework", "pydanticai", + "--self-hosted", + "--output-dir", str(out), + ]) + assert result.exit_code == 0, result.output + assert (out / "backend" / "app" / "main.py").exists() + # The domain id comes from the YAML, not from --domain + env = (out / ".env").read_text() + assert "DOMAIN_ID=test-domain" in env + + def test_ontology_yaml_copied_into_scaffold(self, runner, tmp_path, ontology_file): + """The docs promise data/ontology.yaml makes each project + self-contained; hand-written ontologies must be copied too.""" + out = tmp_path / "ont-copy-app" + result = runner.invoke(main, [ + "ont-copy-app", + "--ontology-file", str(ontology_file), + "--framework", "pydanticai", + "--self-hosted", + "--output-dir", str(out), + ]) + assert result.exit_code == 0, result.output + copied = out / "data" / "ontology.yaml" + assert copied.exists(), "data/ontology.yaml missing from scaffold" + assert copied.read_text() == ontology_file.read_text() + # _base.yaml ships alongside so `inherits: _base` stays resolvable + assert (out / "data" / "_base.yaml").exists() + + def test_ontology_file_with_demo_data(self, runner, tmp_path, ontology_file): + """--demo-data on a hand-written ontology uses the static fallback + generator (no LLM key) and writes fixtures.json.""" + out = tmp_path / "ont-demo-app" + result = runner.invoke(main, [ + "ont-demo-app", + "--ontology-file", str(ontology_file), + "--framework", "pydanticai", + "--self-hosted", + "--demo-data", + "--output-dir", str(out), + ]) + assert result.exit_code == 0, result.output + fixtures = out / "data" / "fixtures.json" + assert fixtures.exists() + data = json.loads(fixtures.read_text()) + assert "Widget" in data["entities"] + + def test_invalid_yaml_fails_cleanly(self, runner, tmp_path): + bad = tmp_path / "bad.yaml" + bad.write_text("domain:\n id: bad\n name: [unmatched bracket\n") + result = runner.invoke(main, [ + "bad-app", + "--ontology-file", str(bad), + "--framework", "pydanticai", + "--self-hosted", + "--output-dir", str(tmp_path / "bad-out"), + ]) + assert result.exit_code == 1 + assert "Failed to load ontology file" in result.output + + def test_missing_file_rejected_by_click(self, runner, tmp_path): + result = runner.invoke(main, [ + "missing-app", + "--ontology-file", str(tmp_path / "does-not-exist.yaml"), + "--framework", "pydanticai", + "--self-hosted", + ]) + assert result.exit_code == 2 # Click's usage error for Path(exists=True) + + def test_conflicts_with_custom_domain(self, runner, tmp_path, ontology_file): + result = runner.invoke(main, [ + "conflict-app", + "--ontology-file", str(ontology_file), + "--custom-domain", "a bakery domain", + "--framework", "pydanticai", + "--self-hosted", + "--anthropic-api-key", "sk-test", + ]) + assert result.exit_code == 1 + assert "mutually exclusive" in result.output + + def test_auto_slug_from_ontology_domain_id(self, runner, tmp_path, monkeypatch): + """No positional name: the slug derives from the YAML's domain id.""" + from tests.test_custom_domain import VALID_DOMAIN_YAML + + ontology_path = tmp_path / "slug-domain.yaml" + ontology_path.write_text(VALID_DOMAIN_YAML) + monkeypatch.chdir(tmp_path) + result = runner.invoke(main, [ + "--ontology-file", str(ontology_path), + "--framework", "pydanticai", + "--self-hosted", + ]) + assert result.exit_code == 0, result.output + assert (tmp_path / "test-domain-pydanticai-app").is_dir() + + def test_wizard_path_adopts_ontology_file(self, tmp_path, monkeypatch, ontology_file): + """--ontology-file with no project name launches the wizard for the + remaining settings, but the hand-written ontology must win over the + wizard's domain pick (and still land in data/ontology.yaml).""" + import sys as _sys + from types import SimpleNamespace + + from create_context_graph import wizard as wizard_mod + from create_context_graph.config import ProjectConfig + + out = tmp_path / "wiz-out" + canned = ProjectConfig( + project_name="wiz app", + domain="healthcare", # wizard's pick — must be overridden + framework="pydanticai", + memory_backend="bolt", + ) + monkeypatch.setattr(wizard_mod, "run_wizard", lambda **kw: canned) + # Pretend stdin is a TTY so main() reaches the wizard branch + monkeypatch.setattr(_sys, "stdin", SimpleNamespace(isatty=lambda: True)) + + main.main( + [ + "--ontology-file", str(ontology_file), + "--self-hosted", + "--output-dir", str(out), + ], + standalone_mode=False, + ) + + assert (out / "data" / "ontology.yaml").read_text() == ontology_file.read_text() + assert "DOMAIN_ID=test-domain" in (out / ".env").read_text() diff --git a/tests/test_custom_domain.py b/tests/test_custom_domain.py index 76def35..3e8ea77 100644 --- a/tests/test_custom_domain.py +++ b/tests/test_custom_domain.py @@ -563,3 +563,53 @@ def test_missing_domain_still_raises(self, tmp_path): ): with pytest.raises(FileNotFoundError): load_domain("does-not-exist-anywhere") + + +class TestDomainResolutionPrecedence: + """v0.14.0 additions to the issue-#30 fix: resolution order edge cases.""" + + def test_bundled_domain_shadows_custom_with_same_id(self, tmp_path): + """A custom domain reusing a bundled id must NOT override the + bundled definition (bundled dir is searched first).""" + custom_dir = tmp_path / "custom-domains" + custom_dir.mkdir() + impostor = VALID_DOMAIN_YAML.replace("id: test-domain", "id: healthcare") + (custom_dir / "healthcare.yaml").write_text(impostor) + + with patch( + "create_context_graph.ontology._get_custom_domains_path", + return_value=custom_dir, + ): + ont = load_domain("healthcare") + + labels = {et.label for et in ont.entity_types} + assert "Widget" not in labels # impostor's marker label + assert "Patient" in labels # real healthcare domain + + def test_corrupt_custom_yaml_is_skipped_in_id_scan(self, tmp_path): + """A broken YAML file in the custom dir must not break resolution of + other custom domains via the declared-id fallback.""" + custom_dir = tmp_path / "custom-domains" + custom_dir.mkdir() + (custom_dir / "broken.yaml").write_text("domain: [unclosed\n") + (custom_dir / "renamed-file.yaml").write_text(VALID_DOMAIN_YAML) + + with patch( + "create_context_graph.ontology._get_custom_domains_path", + return_value=custom_dir, + ): + ont = load_domain("test-domain") + + assert ont.domain.id == "test-domain" + + def test_underscore_files_ignored_in_id_scan(self, tmp_path): + custom_dir = tmp_path / "custom-domains" + custom_dir.mkdir() + (custom_dir / "_draft.yaml").write_text(VALID_DOMAIN_YAML) + + with patch( + "create_context_graph.ontology._get_custom_domains_path", + return_value=custom_dir, + ): + with pytest.raises(FileNotFoundError): + load_domain("test-domain") diff --git a/tests/test_generated_client_runtime.py b/tests/test_generated_client_runtime.py new file mode 100644 index 0000000..aa40d94 --- /dev/null +++ b/tests/test_generated_client_runtime.py @@ -0,0 +1,695 @@ +# Copyright 2026 Neo4j Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Behavioral tests for the generated ``context_graph_client.py`` and +``memory.py`` modules. + +Most template coverage in this repo is static (render + assert on source +text). These tests execute the rendered modules against doubles, pinning the +two v0.14.0 runtime behaviors that static checks can't see: + +* ``execute_cypher`` dispatches to the NAMS ``client.query.cypher`` API when + ``MEMORY_BACKEND=nams`` (agent tools were previously dead on NAMS — the + bolt driver is never connected there), including result-shape coercion and + tool-event emission (PR #56). +* The bolt path opens sessions against ``settings.neo4j_database`` and + ``store_message()`` records write failures into the error state that + ``/health`` reports (PR #60). +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from create_context_graph.config import ProjectConfig +from create_context_graph.ontology import load_domain +from create_context_graph.renderer import ProjectRenderer + +pytest.importorskip("neo4j") # generated client imports the driver at module level + + +# --------------------------------------------------------------------------- +# Scaffolds (rendered once per module — the tests only read the output files) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def bolt_backend_dir(tmp_path_factory) -> Path: + cfg = ProjectConfig( + project_name="bolt client runtime", + domain="healthcare", + framework="pydanticai", + memory_backend="bolt", + neo4j_uri="neo4j://localhost:7687", + ) + out = tmp_path_factory.mktemp("bolt-client-scaffold") + ProjectRenderer(cfg, load_domain(cfg.domain)).render(out) + return out / "backend" + + +@pytest.fixture(scope="module") +def nams_backend_dir(tmp_path_factory) -> Path: + cfg = ProjectConfig( + project_name="nams client runtime", + domain="healthcare", + framework="strands", + memory_backend="nams", + nams_api_key="sk-test", + ) + out = tmp_path_factory.mktemp("nams-client-scaffold") + ProjectRenderer(cfg, load_domain(cfg.domain)).render(out) + return out / "backend" + + +# --------------------------------------------------------------------------- +# Module loader + doubles +# --------------------------------------------------------------------------- + + +def _make_settings(**overrides) -> SimpleNamespace: + base = dict( + memory_backend="bolt", + memory_api_key="sk-test", + memory_nams_endpoint="https://memory.neo4jlabs.com/v1", + memory_llm="", + memory_embedding="", + anthropic_api_key=None, + openai_api_key=None, + neo4j_uri="neo4j://localhost:7687", + neo4j_username="neo4j", + neo4j_password="pw", + neo4j_database="", + session_strategy="per_conversation", + domain_id="healthcare", + ) + base.update(overrides) + return SimpleNamespace(**base) + + +def _install_app_stubs(settings: SimpleNamespace, memory_client=None) -> None: + """Install ``app``/``app.config``/``app.memory`` stubs into sys.modules.""" + app_mod = ModuleType("app") + config_mod = ModuleType("app.config") + config_mod.settings = settings + memory_mod = ModuleType("app.memory") + memory_mod.get_client = lambda: memory_client + memory_mod.connect_memory = AsyncMock() + memory_mod.close_memory = AsyncMock() + sys.modules["app"] = app_mod + sys.modules["app.config"] = config_mod + sys.modules["app.memory"] = memory_mod + + +def _load_module(path: Path, name: str) -> ModuleType: + import importlib.util + + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(autouse=True) +def _cleanup_modules(): + yield + for key in list(sys.modules): + if key == "app" or key.startswith("app.") or key.startswith("generated_"): + del sys.modules[key] + # Only remove neo4j_agent_memory if it's our stub — never a real install. + if getattr(sys.modules.get("neo4j_agent_memory"), "__ccg_test_stub__", False): + del sys.modules["neo4j_agent_memory"] + + +class _FakeResult: + """Async-iterable of record doubles, matching ``await session.run(...)``.""" + + def __init__(self, records: list[dict]): + self._records = [SimpleNamespace(items=lambda d=r: d.items()) for r in records] + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._records: + raise StopAsyncIteration + return self._records.pop(0) + + +class _FakeSession: + def __init__(self, records: list[dict]): + self._records = records + self.queries: list[tuple[str, dict, Any]] = [] + + async def run(self, query, parameters=None, timeout=None, **kw): + self.queries.append((query, dict(parameters or {}), timeout)) + return _FakeResult(self._records) + + +class _FakeDriver: + def __init__(self, records: list[dict] | None = None): + self.session_obj = _FakeSession(records or []) + self.session_kwargs: list[dict] = [] + + def session(self, **kwargs): + self.session_kwargs.append(dict(kwargs)) + outer = self + + class _Ctx: + async def __aenter__(self_inner): + return outer.session_obj + + async def __aexit__(self_inner, *_): + return None + + return _Ctx() + + +def _fake_nams_client(cypher_result=None) -> MagicMock: + client = MagicMock() + client.query.cypher = AsyncMock(return_value=cypher_result) + return client + + +# --------------------------------------------------------------------------- +# NAMS dispatch (PR #56) +# --------------------------------------------------------------------------- + + +class TestExecuteCypherOnNams: + def _load(self, nams_backend_dir, client): + settings = _make_settings(memory_backend="nams") + _install_app_stubs(settings, memory_client=client) + return _load_module( + nams_backend_dir / "app" / "context_graph_client.py", + "generated_cgc_nams", + ) + + async def test_dispatches_to_nams_query_api(self, nams_backend_dir): + client = _fake_nams_client([{"name": "Alice"}]) + cgc = self._load(nams_backend_dir, client) + + records = await cgc.execute_cypher("MATCH (n) RETURN n", {"x": 1}) + + client.query.cypher.assert_awaited_once_with("MATCH (n) RETURN n", {"x": 1}) + assert records == [{"name": "Alice"}] + + async def test_raises_when_client_not_connected(self, nams_backend_dir): + cgc = self._load(nams_backend_dir, client=None) + + with pytest.raises(RuntimeError, match="NAMS client not connected"): + await cgc.execute_cypher("MATCH (n) RETURN n") + + @pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, []), + ([{"a": 1}], [{"a": 1}]), + ({"results": [{"a": 1}]}, [{"a": 1}]), + ({"data": [{"b": 2}]}, [{"b": 2}]), + ({"rows": [{"c": 3}]}, [{"c": 3}]), + ({"count": 7}, [{"count": 7}]), # bare row object wraps + (({"a": 1},), [{"a": 1}]), # tuple normalizes to list + (42, [42]), # scalar wraps + ], + ) + async def test_result_shape_coercion(self, nams_backend_dir, raw, expected): + client = _fake_nams_client(raw) + cgc = self._load(nams_backend_dir, client) + + assert await cgc.execute_cypher("RETURN 1") == expected + + async def test_tool_events_are_collected(self, nams_backend_dir): + client = _fake_nams_client([{"name": "Alice"}]) + cgc = self._load(nams_backend_dir, client) + collector = cgc.get_collector() + collector.drain() + collector.drain_tool_calls() + + await cgc.execute_cypher( + "MATCH (n) RETURN n", {"q": "alice"}, tool_name="search_patients" + ) + + assert collector.drain() == [{"name": "Alice"}] + calls = collector.drain_tool_calls() + assert len(calls) == 1 + assert calls[0]["name"] == "search_patients" + assert calls[0]["inputs"] == {"q": "alice"} + + async def test_collect_false_skips_collector(self, nams_backend_dir): + client = _fake_nams_client([{"name": "Alice"}]) + cgc = self._load(nams_backend_dir, client) + collector = cgc.get_collector() + collector.drain() + collector.drain_tool_calls() + + await cgc.execute_cypher("MATCH (n) RETURN n", collect=False, tool_name="t") + + assert collector.drain() == [] + assert collector.drain_tool_calls() == [] + + async def test_bolt_driver_never_touched_on_nams(self, nams_backend_dir): + """The pre-PR-#56 failure mode: agent tools hit get_driver() on NAMS + and died with "Neo4j not connected". The NAMS branch must return + before any driver access.""" + client = _fake_nams_client([]) + cgc = self._load(nams_backend_dir, client) + assert cgc._driver is None # never connected + + await cgc.execute_cypher("MATCH (n) RETURN n") # must not raise + + +# --------------------------------------------------------------------------- +# Bolt database threading (PR #60) +# --------------------------------------------------------------------------- + + +class TestExecuteCypherOnBolt: + def _load(self, bolt_backend_dir, *, database: str, records=None): + settings = _make_settings(memory_backend="bolt", neo4j_database=database) + _install_app_stubs(settings) + cgc = _load_module( + bolt_backend_dir / "app" / "context_graph_client.py", + "generated_cgc_bolt", + ) + driver = _FakeDriver(records or []) + cgc._driver = driver + return cgc, driver + + async def test_session_uses_configured_database(self, bolt_backend_dir): + cgc, driver = self._load(bolt_backend_dir, database="clinical-db") + + await cgc.execute_cypher("RETURN 1") + + assert driver.session_kwargs == [{"database": "clinical-db"}] + + async def test_blank_database_defers_to_server_default(self, bolt_backend_dir): + cgc, driver = self._load(bolt_backend_dir, database="") + + await cgc.execute_cypher("RETURN 1") + + assert driver.session_kwargs == [{"database": None}] + + async def test_records_serialized_from_session(self, bolt_backend_dir): + cgc, driver = self._load( + bolt_backend_dir, database="", records=[{"n": 1}, {"n": 2}] + ) + + records = await cgc.execute_cypher("MATCH (n) RETURN n", {"k": "v"}) + + assert records == [{"n": 1}, {"n": 2}] + query, params, timeout = driver.session_obj.queries[0] + assert params == {"k": "v"} + assert timeout == 30.0 + + async def test_nams_client_never_touched_on_bolt(self, bolt_backend_dir): + cgc, driver = self._load(bolt_backend_dir, database="") + # app.memory.get_client would raise if consulted — replace to detect + sentinel = MagicMock(side_effect=AssertionError("NAMS path taken on bolt")) + sys.modules["app.memory"].get_client = sentinel + + await cgc.execute_cypher("RETURN 1") + + sentinel.assert_not_called() + + +# --------------------------------------------------------------------------- +# Generated memory.py — database pass-through + store failure surfacing +# --------------------------------------------------------------------------- + + +def _install_memory_lib_stub() -> ModuleType: + """Stub neo4j_agent_memory with capture-friendly doubles.""" + fake = ModuleType("neo4j_agent_memory") + fake.__ccg_test_stub__ = True + fake.MemorySettings = MagicMock(name="MemorySettings") + fake.NamsConfig = MagicMock(name="NamsConfig") + fake.MemoryClient = MagicMock(name="MemoryClient") + fake.MemoryIntegration = MagicMock(name="MemoryIntegration") + + class _SessionStrategy: + PER_CONVERSATION = "per_conversation" + PER_DAY = "per_day" + PERSISTENT = "persistent" + + fake.SessionStrategy = _SessionStrategy + + class _NotSupportedError(Exception): + pass + + fake.NotSupportedError = _NotSupportedError + sys.modules["neo4j_agent_memory"] = fake + return fake + + +class TestGeneratedMemorySettings: + def _load(self, bolt_backend_dir, *, database: str): + settings = _make_settings(memory_backend="bolt", neo4j_database=database) + _install_app_stubs(settings) + lib = _install_memory_lib_stub() + mem = _load_module( + bolt_backend_dir / "app" / "memory.py", "generated_memory" + ) + return mem, lib + + def test_database_passed_to_memory_settings(self, bolt_backend_dir): + mem, lib = self._load(bolt_backend_dir, database="clinical-db") + + mem._build_memory_settings() + + neo4j_config = lib.MemorySettings.call_args.kwargs["neo4j"] + assert neo4j_config["database"] == "clinical-db" + assert neo4j_config["uri"] == "neo4j://localhost:7687" + + def test_blank_database_omitted_from_memory_settings(self, bolt_backend_dir): + """Omitting the key (rather than passing "") defers to + neo4j-agent-memory's own default of "neo4j".""" + mem, lib = self._load(bolt_backend_dir, database="") + + mem._build_memory_settings() + + neo4j_config = lib.MemorySettings.call_args.kwargs["neo4j"] + assert "database" not in neo4j_config + + +class TestStoreMessageErrorSurfacing: + """PR #60: store_message() failures must reach get_error_category() — + previously a bad database name meant every write failed with only a log + line while /health kept reporting "ok".""" + + def _load(self, bolt_backend_dir): + settings = _make_settings(memory_backend="bolt") + _install_app_stubs(settings) + _install_memory_lib_stub() + return _load_module( + bolt_backend_dir / "app" / "memory.py", "generated_memory_store" + ) + + async def test_write_failure_records_error_state(self, bolt_backend_dir): + mem = self._load(bolt_backend_dir) + mem._memory = SimpleNamespace( + store_message=AsyncMock(side_effect=ConnectionError("db gone")) + ) + + result = await mem.store_message("s-1", "user", "hello") + + assert result is None + assert mem.get_error_category() == "network" + assert mem.get_error_detail() == "ConnectionError" + assert mem.get_error_message() is not None + + async def test_successful_write_clears_error_state(self, bolt_backend_dir): + mem = self._load(bolt_backend_dir) + mem._memory = SimpleNamespace( + store_message=AsyncMock(side_effect=ConnectionError("boom")) + ) + await mem.store_message("s-1", "user", "hello") + assert mem.get_error_category() == "network" + + mem._memory = SimpleNamespace( + store_message=AsyncMock(return_value={"entities": []}) + ) + result = await mem.store_message("s-1", "user", "hello again") + + assert result == {"entities": []} + assert mem.get_error_category() is None + assert mem.get_error_detail() is None + + async def test_not_supported_error_does_not_degrade(self, bolt_backend_dir): + """NotSupportedError is expected partial behavior on NAMS — it must + not flip /health to degraded.""" + mem = self._load(bolt_backend_dir) + lib = sys.modules["neo4j_agent_memory"] + mem._memory = SimpleNamespace( + store_message=AsyncMock(side_effect=lib.NotSupportedError("no prefs")) + ) + + result = await mem.store_message("s-1", "user", "hello") + + assert result is None + assert mem.get_error_category() is None + + async def test_no_memory_preserves_startup_error(self, bolt_backend_dir): + """When connect failed at startup, store_message must not clobber the + recorded startup error with fresher state.""" + mem = self._load(bolt_backend_dir) + mem._memory = None + mem._error_category = "auth" + mem._error_detail = "HTTP 401" + + result = await mem.store_message("s-1", "user", "hello") + + assert result is None + assert mem.get_error_category() == "auth" + + +class TestClassifyMemoryError: + """Pin the error classifier buckets the /health endpoint reports.""" + + @pytest.fixture() + def classify(self, bolt_backend_dir): + settings = _make_settings(memory_backend="bolt") + _install_app_stubs(settings) + _install_memory_lib_stub() + mem = _load_module( + bolt_backend_dir / "app" / "memory.py", "generated_memory_classify" + ) + return mem._classify_memory_error + + def test_http_status_buckets(self, classify): + class _Http(Exception): + def __init__(self, status): + self.status_code = status + + assert classify(_Http(401))[0] == "auth" + assert classify(_Http(403))[0] == "auth" + assert classify(_Http(429))[0] == "rate_limit" + assert classify(_Http(503))[0] == "network" + + def test_network_exception_types(self, classify): + assert classify(ConnectionError("x"))[0] == "network" + assert classify(TimeoutError("x"))[0] == "network" + assert classify(OSError("x"))[0] == "network" + + def test_message_scan_buckets(self, classify): + assert classify(Exception("401 unauthorized"))[0] == "auth" + assert classify(Exception("rate limit exceeded"))[0] == "rate_limit" + assert classify(Exception("connection refused"))[0] == "network" + assert classify(Exception("MEMORY_API_KEY missing"))[0] == "config" + assert classify(Exception("something odd"))[0] == "unknown" + + +class TestNamsConversationTranslation: + """v0.14.0 live-service fixes: the NAMS service only accepts messages + addressed to conversation ids IT minted — client-chosen session ids 404 + ("conversation not found") and neo4j-agent-memory <=0.5.0 posts them + straight through, so every chat memory write silently failed.""" + + def _load(self, nams_backend_dir): + settings = _make_settings(memory_backend="nams") + _install_app_stubs(settings) + _install_memory_lib_stub() + mem = _load_module( + nams_backend_dir / "app" / "memory.py", "generated_memory_nams" + ) + client = MagicMock() + client.short_term.create_conversation = AsyncMock( + return_value=SimpleNamespace(id="conv-uuid-1") + ) + mem._client = client + mem._memory = SimpleNamespace( + store_message=AsyncMock(return_value={"entities": []}), + get_context=AsyncMock( + return_value={"messages": [], "entities": [], "preferences": [], "traces": []} + ), + ) + return mem, client + + async def test_store_message_targets_server_conversation_id(self, nams_backend_dir): + mem, client = self._load(nams_backend_dir) + + await mem.store_message("app-session-1", "user", "hello") + + client.short_term.create_conversation.assert_awaited_once_with( + session_id="app-session-1" + ) + kwargs = mem._memory.store_message.await_args.kwargs + assert kwargs["session_id"] == "conv-uuid-1" + + async def test_conversation_created_once_per_session(self, nams_backend_dir): + mem, client = self._load(nams_backend_dir) + + await mem.store_message("app-session-1", "user", "one") + await mem.store_message("app-session-1", "assistant", "two") + await mem.get_context("app-session-1", query="x") + + assert client.short_term.create_conversation.await_count == 1 + ctx_kwargs = mem._memory.get_context.await_args.kwargs + assert ctx_kwargs["session_id"] == "conv-uuid-1" + + async def test_create_failure_falls_back_to_raw_session(self, nams_backend_dir): + mem, client = self._load(nams_backend_dir) + client.short_term.create_conversation = AsyncMock( + side_effect=ConnectionError("service down") + ) + + await mem.store_message("app-session-2", "user", "hello") + + kwargs = mem._memory.store_message.await_args.kwargs + assert kwargs["session_id"] == "app-session-2" + + async def test_bolt_backend_skips_translation(self, bolt_backend_dir): + settings = _make_settings(memory_backend="bolt") + _install_app_stubs(settings) + _install_memory_lib_stub() + mem = _load_module( + bolt_backend_dir / "app" / "memory.py", "generated_memory_bolt_skip" + ) + sentinel = MagicMock() + sentinel.short_term.create_conversation = AsyncMock() + mem._client = sentinel + mem._memory = SimpleNamespace( + store_message=AsyncMock(return_value={"entities": []}) + ) + + await mem.store_message("bolt-session", "user", "hello") + + sentinel.short_term.create_conversation.assert_not_awaited() + assert mem._memory.store_message.await_args.kwargs["session_id"] == "bolt-session" + + +class TestStoreMessageSwallowedErrors: + """MemoryIntegration swallows failures into {'error': ...} return values + (observed live) — store_message must treat those as failures, not clear + the error state and report success.""" + + async def test_error_dict_return_records_error_state(self, bolt_backend_dir): + settings = _make_settings(memory_backend="bolt") + _install_app_stubs(settings) + _install_memory_lib_stub() + mem = _load_module( + bolt_backend_dir / "app" / "memory.py", "generated_memory_errdict" + ) + mem._memory = SimpleNamespace( + store_message=AsyncMock( + return_value={"error": "NAMS POST /conversations/x/messages → 404: conversation not found"} + ) + ) + + result = await mem.store_message("s-1", "user", "hello") + + assert result is None + assert mem.get_error_category() is not None + assert mem.get_error_detail() is not None + + +class TestGeneratedEnsureNamsOntology: + """v0.14.0: connect_memory() binds the NAMS workspace to the app's domain + ontology — activate from the server catalog, or create from the + scaffold's ontology_document.json for custom domains.""" + + def _load(self, nams_backend_dir, *, active="nams-default", catalog=("healthcare",), + domain_id="healthcare"): + settings = _make_settings(memory_backend="nams", domain_id=domain_id) + _install_app_stubs(settings) + _install_memory_lib_stub() + mem = _load_module( + nams_backend_dir / "app" / "memory.py", "generated_memory_ontology" + ) + client = MagicMock() + client.ontology.get_active = AsyncMock(return_value=SimpleNamespace( + document=SimpleNamespace(domain=SimpleNamespace(id=active)) + )) + client.ontology.list = AsyncMock(return_value=[ + SimpleNamespace(id=f"ont-{name}", name=name) for name in catalog + ]) + client.ontology.get = AsyncMock(return_value=SimpleNamespace( + versions=[SimpleNamespace(id="ov-1", revision=1), + SimpleNamespace(id="ov-2", revision=2)] + )) + client.ontology.activate = AsyncMock(return_value=SimpleNamespace(id="ov-2")) + client.ontology.create = AsyncMock(return_value=SimpleNamespace(id="ov-new")) + mem._client = client + return mem, client + + async def test_activates_catalog_match(self, nams_backend_dir): + mem, client = self._load(nams_backend_dir) + + await mem._ensure_nams_ontology() + + client.ontology.get.assert_awaited_once_with(ontology_id="ont-healthcare") + client.ontology.activate.assert_awaited_once_with(version_id="ov-2") + + async def test_noop_when_already_active(self, nams_backend_dir): + mem, client = self._load(nams_backend_dir, active="healthcare") + + await mem._ensure_nams_ontology() + + client.ontology.list.assert_not_awaited() + client.ontology.activate.assert_not_awaited() + + async def test_creates_from_scaffold_document_for_unknown_domain(self, nams_backend_dir): + """The scaffold ships app/ontology_document.json — an off-catalog + domain id creates the ontology from it, then activates.""" + mem, client = self._load(nams_backend_dir, catalog=()) + + await mem._ensure_nams_ontology() + + client.ontology.create.assert_awaited_once() + kwargs = client.ontology.create.await_args.kwargs + assert kwargs["name"] == "healthcare" + doc = kwargs["schema"] + assert set(doc.keys()) == {"domain", "entity_types", "relationships"} + assert doc["domain"]["id"] == "healthcare" + assert any(et["label"] == "Patient" for et in doc["entity_types"]) + client.ontology.activate.assert_awaited_once_with(version_id="ov-new") + + async def test_bolt_backend_is_noop(self, bolt_backend_dir): + settings = _make_settings(memory_backend="bolt") + _install_app_stubs(settings) + _install_memory_lib_stub() + mem = _load_module( + bolt_backend_dir / "app" / "memory.py", "generated_memory_ontology_bolt" + ) + client = MagicMock() + client.ontology.get_active = AsyncMock( + side_effect=AssertionError("ontology API touched on bolt") + ) + mem._client = client + + await mem._ensure_nams_ontology() + + client.ontology.get_active.assert_not_awaited() + + async def test_failure_never_raises(self, nams_backend_dir): + mem, client = self._load(nams_backend_dir) + client.ontology.get_active = AsyncMock(side_effect=ConnectionError("down")) + + await mem._ensure_nams_ontology() # must not raise + + def test_connect_memory_calls_ensure(self, nams_backend_dir): + """Template pin: the connect path awaits the ontology bind.""" + source = (nams_backend_dir / "app" / "memory.py").read_text() + assert "await _ensure_nams_ontology()" in source + # and it happens after client connect, before MemoryIntegration + connect_idx = source.index("await _client.connect()") + ensure_idx = source.index("await _ensure_nams_ontology()") + integration_idx = source.index("_memory = MemoryIntegration(") + assert connect_idx < ensure_idx < integration_idx diff --git a/tests/test_generated_project.py b/tests/test_generated_project.py index d1c7e05..13b3ca3 100644 --- a/tests/test_generated_project.py +++ b/tests/test_generated_project.py @@ -2233,3 +2233,235 @@ def test_all_domains_have_minimum_tool_count(self): ontology = load_domain(did) count = len(ontology.agent_tools) assert count >= 7, f"Domain {did} should have >= 7 tools, got {count}" + + +# --------------------------------------------------------------------------- +# v0.14.0 — NEO4J_DATABASE threading, NAMS cypher runtime, scenario fallback +# --------------------------------------------------------------------------- + + +def _render(tmp_path, *, name, domain="healthcare", framework="pydanticai", **cfg): + config = ProjectConfig( + project_name=name, + domain=domain, + framework=framework, + **cfg, + ) + ontology = load_domain(domain) + out = tmp_path / config.project_slug + ProjectRenderer(config, ontology).render(out) + return out + + +class TestV0140Neo4jDatabase: + """PR #60: NEO4J_DATABASE must reach every Neo4j touchpoint in the + scaffold — config, .env, memory settings, raw driver sessions, and the + connector import script.""" + + def test_env_carries_database_value(self, tmp_path): + out = _render( + tmp_path, name="db env", memory_backend="bolt", + neo4j_database="clinical-db", + ) + assert "NEO4J_DATABASE=clinical-db" in (out / ".env").read_text() + + def test_env_example_documents_database(self, tmp_path): + out = _render(tmp_path, name="db envex", memory_backend="bolt") + example = (out / ".env.example").read_text() + assert "NEO4J_DATABASE=" in example + + def test_nams_env_omits_bolt_credentials(self, tmp_path): + out = _render( + tmp_path, name="db nams", framework="strands", + memory_backend="nams", nams_api_key="sk-test", + ) + env = (out / ".env").read_text() + assert "NEO4J_DATABASE" not in env + assert "NEO4J_URI" not in env + + def test_settings_exposes_database_field(self, tmp_path): + out = _render(tmp_path, name="db settings", memory_backend="bolt") + config_py = (out / "backend" / "app" / "config.py").read_text() + assert "neo4j_database: str" in config_py + + def test_execute_cypher_session_is_database_aware(self, tmp_path): + out = _render(tmp_path, name="db client", memory_backend="bolt") + client_py = (out / "backend" / "app" / "context_graph_client.py").read_text() + assert "driver.session(database=settings.neo4j_database or None)" in client_py + + def test_memory_settings_conditionally_pass_database(self, tmp_path): + out = _render(tmp_path, name="db memory", memory_backend="bolt") + memory_py = (out / "backend" / "app" / "memory.py").read_text() + assert 'if settings.neo4j_database:' in memory_py + assert 'neo4j_config["database"] = settings.neo4j_database' in memory_py + + def test_import_data_bolt_session_is_database_aware(self, tmp_path): + out = _render( + tmp_path, name="db import", domain="software-engineering", + memory_backend="bolt", saas_connectors=["linear"], + ) + import_py = (out / "backend" / "scripts" / "import_data.py").read_text() + assert "driver.session(database=settings.neo4j_database or None)" in import_py + + +class TestV0140MemoryErrorSurfacing: + """PR #60: store_message failures reach the /health endpoint.""" + + def test_store_message_records_error_state(self, tmp_path): + out = _render(tmp_path, name="mem err", memory_backend="bolt") + memory_py = (out / "backend" / "app" / "memory.py").read_text() + # The failure handler must write into the shared classified-error state + assert "global _error_category, _error_detail" in memory_py + assert "_error_category, _error_detail = _classify_memory_error(e)" in memory_py + + def test_health_reports_live_memory_errors_on_bolt(self, tmp_path): + out = _render(tmp_path, name="mem health", memory_backend="bolt") + main_py = (out / "backend" / "app" / "main.py").read_text() + assert 'body["memory_error"] = category' in main_py + assert 'body["memory_error_detail"] = get_error_detail()' in main_py + # Memory state is derived from the client, not assumed on connect + assert "get_client() is not None" in main_py + + def test_lifespan_checks_memory_client_after_connect(self, tmp_path): + out = _render(tmp_path, name="mem lifespan", memory_backend="bolt") + main_py = (out / "backend" / "app" / "main.py").read_text() + assert "Neo4j connected but memory is degraded" in main_py + + +class TestV0140NamsCypherTemplate: + """PR #56: execute_cypher works on NAMS via the query REST API.""" + + def _client_py(self, tmp_path): + out = _render( + tmp_path, name="nams cy", framework="strands", + memory_backend="nams", nams_api_key="sk-test", + ) + return out, (out / "backend" / "app" / "context_graph_client.py").read_text() + + def test_nams_dispatch_present(self, tmp_path): + _, client_py = self._client_py(tmp_path) + assert 'if settings.memory_backend == "nams":' in client_py + assert "_execute_nams_cypher" in client_py + assert "client.query.cypher(query, params)" in client_py + + def test_result_coercion_helper_present(self, tmp_path): + _, client_py = self._client_py(tmp_path) + assert "def _coerce_nams_records(raw):" in client_py + for key in ('"results"', '"data"', '"rows"'): + assert key in client_py + + def test_require_neo4j_guards_nams_client(self, tmp_path): + out, _ = self._client_py(tmp_path) + routes_py = (out / "backend" / "app" / "routes.py").read_text() + assert "NAMS client not connected. Check MEMORY_API_KEY" in routes_py + + def test_templates_compile_on_nams(self, tmp_path): + out, client_py = self._client_py(tmp_path) + compile(client_py, "context_graph_client.py", "exec") + routes_py = (out / "backend" / "app" / "routes.py").read_text() + compile(routes_py, "routes.py", "exec") + + +class TestV0140DemoScenarioFallback: + """PR #59 (+ hardening): scaffolds must render even when a domain omits + demo_scenarios entirely or ships a scenario with an empty prompts list.""" + + def _render_with_scenarios(self, tmp_path, scenarios): + config = ProjectConfig( + project_name="scenario fallback", + domain="healthcare", + framework="pydanticai", + memory_backend="bolt", + ) + ontology = load_domain("healthcare").model_copy( + update={"demo_scenarios": scenarios} + ) + out = tmp_path / "scenario-app" + ProjectRenderer(config, ontology).render(out) + return (out / "frontend" / "e2e" / "app.spec.ts").read_text() + + def test_no_scenarios_renders_with_fallback_prompt(self, tmp_path): + spec = self._render_with_scenarios(tmp_path, []) + assert '"Show me the data in the graph"' in spec + + def test_scenario_with_empty_prompts_renders_with_fallback(self, tmp_path): + from create_context_graph.ontology import DemoScenario + + spec = self._render_with_scenarios( + tmp_path, [DemoScenario(name="Empty", prompts=[])] + ) + assert '"Show me the data in the graph"' in spec + + def test_real_scenario_prompt_is_used(self, tmp_path): + from create_context_graph.ontology import DemoScenario + + spec = self._render_with_scenarios( + tmp_path, + [DemoScenario(name="Demo", prompts=["Which patients are at risk?"])], + ) + assert '"Which patients are at risk?"' in spec + assert '"Show me the data in the graph"' not in spec + + +class TestV0140NamsOntologyActivation: + """v0.14.0: scaffolds bind the NAMS workspace to the domain ontology. + + NAMS pre-registers every bundled domain server-side but auto-binds + workspaces to nams-default until an ontology is explicitly activated — + which nothing did before this release. + """ + + def test_ontology_document_json_written(self, tmp_path): + out = _render(tmp_path, name="ont doc", memory_backend="bolt") + path = out / "backend" / "app" / "ontology_document.json" + assert path.exists() + doc = json.loads(path.read_text()) + assert set(doc.keys()) == {"domain", "entity_types", "relationships"} + assert doc["domain"]["id"] == "healthcare" + labels = {et["label"] for et in doc["entity_types"]} + assert {"Patient", "Provider", "Person"} <= labels + # App-side sections must NOT leak into the server document + assert "agent_tools" not in doc and "system_prompt" not in doc + + def test_ontology_document_matches_custom_domain(self, tmp_path): + from tests.test_custom_domain import VALID_DOMAIN_YAML + + from create_context_graph.ontology import load_domain_from_yaml_string + + config = ProjectConfig( + project_name="custom ont doc", + domain="test-domain", + framework="pydanticai", + memory_backend="nams", + nams_api_key="sk-test", + custom_domain_yaml=VALID_DOMAIN_YAML, + ) + ontology = load_domain_from_yaml_string(VALID_DOMAIN_YAML) + out = tmp_path / "custom-ont" + ProjectRenderer(config, ontology).render(out) + + doc = json.loads( + (out / "backend" / "app" / "ontology_document.json").read_text() + ) + assert doc["domain"]["id"] == "test-domain" + assert any(et["label"] == "Widget" for et in doc["entity_types"]) + + def test_memory_template_ensures_ontology(self, tmp_path): + out = _render( + tmp_path, name="ont memory", framework="strands", + memory_backend="nams", nams_api_key="sk-test", + ) + memory_py = (out / "backend" / "app" / "memory.py").read_text() + assert "async def _ensure_nams_ontology" in memory_py + assert "ontology_document.json" in memory_py + compile(memory_py, "memory.py", "exec") + + def test_import_data_template_ensures_ontology(self, tmp_path): + out = _render( + tmp_path, name="ont import", domain="software-engineering", + memory_backend="nams", nams_api_key="sk-test", + framework="strands", saas_connectors=["linear"], + ) + import_py = (out / "backend" / "scripts" / "import_data.py").read_text() + assert "_ensure_nams_ontology(client)" in import_py + compile(import_py, "import_data.py", "exec") diff --git a/tests/test_generated_tests.py b/tests/test_generated_tests.py index badf0a9..6b1b8ff 100644 --- a/tests/test_generated_tests.py +++ b/tests/test_generated_tests.py @@ -31,12 +31,15 @@ from create_context_graph.renderer import ProjectRenderer -def _scaffold_project(tmp_path, domain="financial-services", framework="pydanticai"): +def _scaffold_project( + tmp_path, domain="financial-services", framework="pydanticai", memory_backend="nams" +): """Scaffold a project and return the output directory.""" config = ProjectConfig( project_name="Generated Test App", domain=domain, framework=framework, + memory_backend=memory_backend, neo4j_uri="neo4j://localhost:7687", neo4j_username="neo4j", neo4j_password="testpass123", @@ -127,12 +130,25 @@ class TestGeneratedTestExecution: """Scaffold projects, install deps, and run the generated test suites.""" @pytest.mark.parametrize( - "framework", - ["pydanticai", "claude-agent-sdk", "langgraph", "anthropic-tools"], + ("framework", "memory_backend"), + [ + ("pydanticai", "nams"), + ("claude-agent-sdk", "nams"), + ("langgraph", "nams"), + ("anthropic-tools", "nams"), + # One bolt scaffold so the self-hosted branch of the generated + # test file (memory_error surfacing in /health) executes for real. + ("pydanticai", "bolt"), + ], ) - def test_generated_tests_pass(self, tmp_path, framework): + def test_generated_tests_pass(self, tmp_path, framework, memory_backend): """Scaffold a project, install deps, and verify generated tests pass.""" - project_dir = _scaffold_project(tmp_path, domain="financial-services", framework=framework) + project_dir = _scaffold_project( + tmp_path, + domain="financial-services", + framework=framework, + memory_backend=memory_backend, + ) backend_dir = project_dir / "backend" # Create an isolated venv @@ -201,3 +217,13 @@ def test_generated_tests_pass(self, tmp_path, framework): assert "test_scenarios" in result.stdout, ( f"test_scenarios did not appear in test output for {framework}:\n{result.stdout}" ) + # Backend-specific health tests (v0.14.0) must have run too + expected_health_test = ( + "test_health_degrades_when_nams_client_missing" + if memory_backend == "nams" + else "test_health_surfaces_live_memory_write_failures" + ) + assert expected_health_test in result.stdout, ( + f"{expected_health_test} did not run for {framework}/{memory_backend}:\n" + f"{result.stdout}" + ) diff --git a/tests/test_ingest_nams.py b/tests/test_ingest_nams.py index fe77ca7..ef96835 100644 --- a/tests/test_ingest_nams.py +++ b/tests/test_ingest_nams.py @@ -54,12 +54,30 @@ def __init__(self): ) self.short_term = SimpleNamespace( add_message=AsyncMock(return_value=SimpleNamespace(id="msg-1")), + # Live NAMS mints its own conversation ids — the ingestors must + # address messages to this returned id, not their session hint. + create_conversation=AsyncMock(return_value=SimpleNamespace(id="conv-srv-1")), ) self.reasoning = SimpleNamespace( start_trace=AsyncMock(return_value=SimpleNamespace(id="trace-1")), add_step=AsyncMock(return_value=SimpleNamespace(id="step-1")), complete_trace=AsyncMock(return_value=None), ) + # Workspace on nams-default with the healthcare domain in the catalog + # (mirrors the live service) — the ingest path activates it first. + self.ontology = SimpleNamespace( + get_active=AsyncMock(return_value=SimpleNamespace( + document=SimpleNamespace(domain=SimpleNamespace(id="nams-default")) + )), + list=AsyncMock(return_value=[ + SimpleNamespace(id="ont-hc", name="healthcare"), + ]), + get=AsyncMock(return_value=SimpleNamespace( + versions=[SimpleNamespace(id="ov-hc-1", revision=1)] + )), + activate=AsyncMock(return_value=SimpleNamespace(id="ov-hc-1")), + create=AsyncMock(return_value=SimpleNamespace(id="ov-created")), + ) async def __aenter__(self): return self @@ -263,11 +281,16 @@ def test_nams_path_dual_tracks_documents( ) ingest_data(fixture, healthcare_ontology, cfg) - # Document message side. + # Document message side. Live NAMS rejects custom roles and only + # accepts the conversation id it minted at create time (v0.14.0). + assert fake_client.short_term.create_conversation.await_count == 1 + conv_kwargs = fake_client.short_term.create_conversation.await_args.kwargs + assert conv_kwargs["session_id"].startswith("docs-") assert fake_client.short_term.add_message.await_count == 1 msg_kwargs = fake_client.short_term.add_message.await_args.kwargs - assert msg_kwargs["role"] == "document" - assert msg_kwargs["session_id"].startswith("docs-") + assert msg_kwargs["role"] == "user" + assert msg_kwargs["session_id"] == "conv-srv-1" + assert msg_kwargs["metadata"]["kind"] == "document" assert msg_kwargs["metadata"]["title"] == "Discharge Note — Bob Singh" # Document entity side. @@ -390,14 +413,11 @@ def test_legacy_bolt_signature_still_dispatches( class TestResetMemoryStoreDispatch: - def test_nams_reset_calls_delete_entity_for_each(self, fake_client, fake_nams_module): - fake_client.long_term.search_entities = AsyncMock( - return_value=[ - SimpleNamespace(id="e1"), - SimpleNamespace(id="e2"), - SimpleNamespace(id="e3"), - ] - ) + def test_nams_reset_reports_unavailable(self, fake_client, fake_nams_module, capsys): + """v0.14.0: NAMS REST / neo4j-agent-memory 0.5.x has no delete API — + reset must say so (with the live entity count) instead of silently + reporting "0 entities removed" like the old swallow-everything loop.""" + fake_client.query = SimpleNamespace(cypher=AsyncMock(return_value=[{"n": 3}])) cfg = ProjectConfig( project_name="x", domain="healthcare", @@ -405,7 +425,16 @@ def test_nams_reset_calls_delete_entity_for_each(self, fake_client, fake_nams_mo nams_api_key="sk-test", ) reset_memory_store(cfg) - assert fake_client.long_term.delete_entity.await_count == 3 + out = capsys.readouterr().out + assert "reset is not available" in out + assert "3 stored entities" in out + # Assert the full sentence, not a bare hostname substring — a + # `"host.com" in text` check pattern-matches CodeQL's + # incomplete-URL-sanitization rule (py/incomplete-url-substring-sanitization) + # even in test assertions. + assert "Manage stored data at https://memory.neo4jlabs.com" in out + # No deletes attempted — the API doesn't exist upstream. + assert fake_client.long_term.delete_entity.await_count == 0 def test_nams_reset_without_api_key_warns(self, capsys): cfg = ProjectConfig( @@ -427,5 +456,101 @@ def test_bolt_reset_uses_neo4j_driver(self): with patch("create_context_graph.ingest.reset_neo4j") as mock_reset: reset_memory_store(cfg) mock_reset.assert_called_once_with( - cfg.neo4j_uri, cfg.neo4j_username, cfg.neo4j_password + cfg.neo4j_uri, + cfg.neo4j_username, + cfg.neo4j_password, + cfg.neo4j_database, ) + + +class TestEnsureNamsOntology: + """v0.14.0: bind the NAMS workspace to the domain ontology before writes. + + NAMS auto-binds workspaces to nams-default until an explicit ontology is + activated; it pre-registers all bundled domains server-side, and every + stored entity is stamped with the active ontology version. + """ + + def _client(self, *, active="nams-default", catalog=("healthcare",)): + client = _FakeNamsClient() + client.ontology.get_active = AsyncMock(return_value=SimpleNamespace( + document=SimpleNamespace(domain=SimpleNamespace(id=active)) + )) + client.ontology.list = AsyncMock(return_value=[ + SimpleNamespace(id=f"ont-{name}", name=name) for name in catalog + ]) + return client + + async def test_already_active_is_noop(self): + from create_context_graph.ingest import ensure_nams_ontology + + client = self._client(active="healthcare") + status = await ensure_nams_ontology(client, "healthcare") + + assert status == "already-active" + client.ontology.list.assert_not_awaited() + client.ontology.activate.assert_not_awaited() + + async def test_catalog_match_activates_latest_version(self): + from create_context_graph.ingest import ensure_nams_ontology + + client = self._client() + client.ontology.get = AsyncMock(return_value=SimpleNamespace(versions=[ + SimpleNamespace(id="ov-1", revision=1), + SimpleNamespace(id="ov-3", revision=3), + SimpleNamespace(id="ov-2", revision=2), + ])) + status = await ensure_nams_ontology(client, "healthcare") + + assert status == "activated" + client.ontology.get.assert_awaited_once_with(ontology_id="ont-healthcare") + client.ontology.activate.assert_awaited_once_with(version_id="ov-3") + client.ontology.create.assert_not_awaited() + + async def test_missing_domain_creates_from_document(self): + from create_context_graph.ingest import ensure_nams_ontology + + client = self._client(catalog=("healthcare",)) + doc = {"domain": {"id": "bakery"}, "entity_types": [], "relationships": []} + status = await ensure_nams_ontology(client, "bakery", doc) + + assert status == "created" + client.ontology.create.assert_awaited_once_with(name="bakery", schema=doc) + client.ontology.activate.assert_awaited_once_with(version_id="ov-created") + + async def test_missing_domain_without_document_reports_unavailable(self): + from create_context_graph.ingest import ensure_nams_ontology + + client = self._client(catalog=()) + status = await ensure_nams_ontology(client, "bakery") + + assert status == "unavailable" + client.ontology.activate.assert_not_awaited() + + async def test_api_failure_is_swallowed(self): + from create_context_graph.ingest import ensure_nams_ontology + + client = self._client() + client.ontology.get_active = AsyncMock(side_effect=ConnectionError("down")) + status = await ensure_nams_ontology(client, "healthcare") + + assert status == "unavailable" + + def test_run_nams_ingest_ensures_ontology_before_writes( + self, tmp_path, healthcare_ontology, fake_client, fake_nams_module + ): + """The ingest pipeline must activate the domain ontology before the + first entity write.""" + fixture = _make_fixture_file(tmp_path) + cfg = ProjectConfig( + project_name="x", + domain="healthcare", + framework="strands", + nams_api_key="sk-test", + ) + ingest_data(fixture, healthcare_ontology, cfg) + + fake_client.ontology.activate.assert_awaited_once_with(version_id="ov-hc-1") + # create not needed — healthcare is in the catalog double, and the + # document argument only comes into play for unknown domains. + fake_client.ontology.create.assert_not_awaited() diff --git a/tests/test_integration.py b/tests/test_integration.py index a6aca09..fbb00fb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -23,6 +23,7 @@ DomainOntology, generate_cypher_schema, load_domain, + split_cypher_statements, ) from create_context_graph.ingest import ingest_data @@ -139,14 +140,12 @@ def test_schema_constraints_apply(self, neo4j_session): schema_ddl = generate_cypher_schema(ontology) # Execute each statement — should not raise - for statement in schema_ddl.split(";"): - stmt = statement.strip() - if stmt and not stmt.startswith("//"): - try: - neo4j_session.run(stmt) - except Exception as exc: - if "already exists" not in str(exc).lower(): - raise + for stmt in split_cypher_statements(schema_ddl): + try: + neo4j_session.run(stmt) + except Exception as exc: + if "already exists" not in str(exc).lower(): + raise result = neo4j_session.run("SHOW CONSTRAINTS") constraints = list(result) @@ -157,18 +156,20 @@ def test_schema_indexes_apply(self, neo4j_session): ontology = load_domain("financial-services") schema_ddl = generate_cypher_schema(ontology) - for statement in schema_ddl.split(";"): - stmt = statement.strip() - if stmt and not stmt.startswith("//"): - try: - neo4j_session.run(stmt) - except Exception as exc: - if "already exists" not in str(exc).lower(): - raise + for stmt in split_cypher_statements(schema_ddl): + try: + neo4j_session.run(stmt) + except Exception as exc: + if "already exists" not in str(exc).lower(): + raise result = neo4j_session.run("SHOW INDEXES") - indexes = list(result) - assert len(indexes) > 0, "Expected at least one index after schema application" + index_names = {record["name"] for record in result} + assert len(index_names) > 0, "Expected at least one index after schema application" + # These four sat behind comment headers, so the old naive splitter + # silently skipped them (v0.14.0 regression guard). + for expected in ("person_name", "document_title", "document_domain"): + assert expected in index_names, f"index {expected} was not created" # --------------------------------------------------------------------------- @@ -331,3 +332,83 @@ def test_domain_filter_isolates_data(self, neo4j_session): f"Found {count} nodes with label '{label}' in domain '{self.DOMAIN_A}' " f"— expected 0 (label is healthcare-only)" ) + + +# --------------------------------------------------------------------------- +# TestNeo4jDatabaseThreading (v0.14.0) +# --------------------------------------------------------------------------- + + +class TestNeo4jDatabaseThreading: + """v0.14.0: an explicitly-named database must be honored end-to-end. + + We resolve the server's default database name and pass it EXPLICITLY — + equivalent routing, but it exercises the ``neo4j_database`` parameter + through ``ProjectConfig`` → ``ingest_data`` → session, on any edition + (community has a single database, so an explicit name is the only + portable way to test the threading). + """ + + TEST_DOMAIN = "test-dbthread" + + @pytest.fixture() + def default_db_name(self, neo4j_driver) -> str: + with neo4j_driver.session() as session: + record = session.run("CALL db.info() YIELD name RETURN name").single() + return record["name"] + + @pytest.fixture(autouse=True) + def _cleanup(self, neo4j_driver): + yield + with neo4j_driver.session() as session: + session.run( + "MATCH (n) WHERE n.domain = $domain DETACH DELETE n", + {"domain": self.TEST_DOMAIN}, + ) + + def test_ingest_honors_explicit_database(self, neo4j_driver, default_db_name): + from create_context_graph.config import ProjectConfig + + ontology = _override_ontology_domain( + load_domain("financial-services"), self.TEST_DOMAIN + ) + fixture_path = _rewrite_fixture_domain( + _load_fixture("financial-services"), self.TEST_DOMAIN + ) + config = ProjectConfig( + project_name="db threading test", + domain=self.TEST_DOMAIN, + memory_backend="bolt", + neo4j_uri=NEO4J_URI, + neo4j_username=NEO4J_USERNAME, + neo4j_password=NEO4J_PASSWORD, + neo4j_database=default_db_name, + ) + try: + ingest_data(fixture_path, ontology, config) + finally: + fixture_path.unlink(missing_ok=True) + + with neo4j_driver.session(database=default_db_name) as session: + count = session.run( + "MATCH (n) WHERE n.domain = $domain RETURN count(n) AS cnt", + {"domain": self.TEST_DOMAIN}, + ).single()["cnt"] + assert count > 0, "explicit-database ingest wrote no nodes" + + def test_validate_connection_with_database(self, default_db_name): + from create_context_graph.neo4j_validator import validate_connection + + ok, message = validate_connection( + NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD, database=default_db_name + ) + assert ok, message + + def test_validate_connection_rejects_unknown_database(self): + from create_context_graph.neo4j_validator import validate_connection + + ok, _ = validate_connection( + NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD, + database="ccg-no-such-database", + ) + assert not ok diff --git a/tests/test_memory_adapter.py b/tests/test_memory_adapter.py index 078ae9d..dadf090 100644 --- a/tests/test_memory_adapter.py +++ b/tests/test_memory_adapter.py @@ -438,6 +438,9 @@ def test_full_fixture_ingest(self, nams_adapter, capsys): adapter, memory_mod = nams_adapter client = MagicMock() client.long_term.add_entity = AsyncMock(return_value=SimpleNamespace(id="e")) + client.short_term.create_conversation = AsyncMock( + return_value=SimpleNamespace(id="conv-1") + ) client.short_term.add_message = AsyncMock(return_value=SimpleNamespace(id="m")) client.reasoning.start_trace = AsyncMock(return_value=SimpleNamespace(id="t")) client.reasoning.add_step = AsyncMock(return_value=SimpleNamespace(id="s")) diff --git a/tests/test_nams_ingest_parity.py b/tests/test_nams_ingest_parity.py index 718db31..82ba20d 100644 --- a/tests/test_nams_ingest_parity.py +++ b/tests/test_nams_ingest_parity.py @@ -113,8 +113,51 @@ def __init__(self): self.long_term = SimpleNamespace( add_entity=AsyncMock(side_effect=self._record("long_term.add_entity")), ) + + async def _create_conversation(**kw): + # Live NAMS ignores client-chosen ids and mints its own — mirror + # that so the ingestors must use the returned id, not their hint. + self.calls.append(("short_term.create_conversation", _clean(kw))) + return SimpleNamespace(id=f"conv-{len(self.calls)}") + self.short_term = SimpleNamespace( add_message=AsyncMock(side_effect=self._record("short_term.add_message")), + create_conversation=AsyncMock(side_effect=_create_conversation), + ) + + # Ontology namespace — models a workspace on nams-default with the + # healthcare domain available in the catalog, so both ingest paths + # walk the get_active -> list -> get -> activate sequence. + async def _ont_get_active(**kw): + self.calls.append(("ontology.get_active", _clean(kw))) + return SimpleNamespace( + document=SimpleNamespace(domain=SimpleNamespace(id="nams-default")) + ) + + async def _ont_list(**kw): + self.calls.append(("ontology.list", _clean(kw))) + return [SimpleNamespace(id="ont-hc", name="healthcare")] + + async def _ont_get(**kw): + self.calls.append(("ontology.get", _clean(kw))) + return SimpleNamespace( + versions=[SimpleNamespace(id="ov-hc-1", revision=1)] + ) + + async def _ont_activate(**kw): + self.calls.append(("ontology.activate", _clean(kw))) + return SimpleNamespace(id=kw.get("version_id", "ov-hc-1")) + + async def _ont_create(**kw): + self.calls.append(("ontology.create", _clean(kw))) + return SimpleNamespace(id="ov-created") + + self.ontology = SimpleNamespace( + get_active=AsyncMock(side_effect=_ont_get_active), + list=AsyncMock(side_effect=_ont_list), + get=AsyncMock(side_effect=_ont_get), + activate=AsyncMock(side_effect=_ont_activate), + create=AsyncMock(side_effect=_ont_create), ) async def _start_trace(**kw): @@ -181,11 +224,13 @@ def _exec_scaffold_template(client: _RecordingClient) -> dict[str, Any]: memory_api_key="sk-test", memory_nams_endpoint="https://test.example/v1", memory_backend="nams", + domain_id="healthcare", # Bolt-path settings — _ingest_via_bolt reads these even when the # backend is NAMS, because both functions live in the same module. neo4j_uri="neo4j://test:7687", neo4j_username="neo4j", neo4j_password="testpass", + neo4j_database="", ) fake_config_mod = ModuleType("app.config") fake_config_mod.settings = fake_settings @@ -345,8 +390,11 @@ def _doc_entity_present(seq): ) def _doc_message_present(seq): + # Live NAMS only accepts user/assistant/system roles — documents ride + # role="user" with a metadata kind marker (v0.14.0). return any( - n == "short_term.add_message" and kw.get("role") == "document" + n == "short_term.add_message" and kw.get("role") == "user" + and (kw.get("metadata") or {}).get("kind") == "document" and (kw.get("metadata") or {}).get("title") == "Discharge Note — Bob" for n, kw in seq ) @@ -471,7 +519,7 @@ async def __aenter__(self): async def __aexit__(self, *_): return None - def session(self): + def session(self, **kwargs): outer = self class _Ctx: @@ -556,3 +604,22 @@ def test_bolt_ingest_uses_relationship_labels_when_present(): assert "MATCH (b:Provider {name: $target_name})" in cypher assert "MERGE (a)-[r:TREATS]->(b)" in cypher assert params == {"source_name": "Mercy General", "target_name": "Mercy General"} + + +def test_ontology_ensure_runs_first_in_both_paths(): + """v0.14.0: both ingest paths must bind the workspace to the domain + ontology BEFORE any writes — get_active, then (on mismatch) list -> + get -> activate — with identical call shapes.""" + cli_calls = _run_cli_path(_RecordingClient()) + scaffold_calls = _run_scaffold_path(_RecordingClient()) + + expected_head = [ + ("ontology.get_active", {}), + ("ontology.list", {}), + ("ontology.get", {"ontology_id": "ont-hc"}), + ("ontology.activate", {"version_id": "ov-hc-1"}), + ] + assert cli_calls[:4] == expected_head, f"CLI head: {cli_calls[:4]}" + assert scaffold_calls[:4] == expected_head, f"Scaffold head: {scaffold_calls[:4]}" + # No create — healthcare exists in the catalog double. + assert all(n != "ontology.create" for n, _ in cli_calls + scaffold_calls) diff --git a/tests/test_ontology.py b/tests/test_ontology.py index c264be5..3dc581e 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -374,3 +374,118 @@ def test_no_deprecated_colon_syntax_in_rel_patterns(self, domain_id): f"Uses deprecated colon syntax in pipe-separated relationship " f"pattern (e.g., ':TYPE1|:TYPE2'). Use 'TYPE1|TYPE2' instead." ) + + +class TestSplitCypherStatements: + """v0.14.0: the shared DDL splitter must survive comments — the naive + ``split(";")`` + ``startswith("//")`` pattern silently skipped real + statements behind comment headers and executed comment tails as Cypher.""" + + def test_semicolon_inside_comment_does_not_split(self): + from create_context_graph.ontology import split_cypher_statements + + script = ( + "// Create after embeddings are generated; dimensions must match.\n" + "CREATE INDEX a IF NOT EXISTS FOR (n:A) ON (n.name);\n" + ) + statements = split_cypher_statements(script) + assert statements == ["CREATE INDEX a IF NOT EXISTS FOR (n:A) ON (n.name)"] + + def test_statement_behind_comment_header_is_kept(self): + from create_context_graph.ontology import split_cypher_statements + + script = ( + "CREATE INDEX a IF NOT EXISTS FOR (n:A) ON (n.name);\n" + "\n" + "// Section header comment\n" + "CREATE INDEX b IF NOT EXISTS FOR (n:B) ON (n.name);\n" + ) + statements = split_cypher_statements(script) + assert len(statements) == 2 + assert statements[1].startswith("CREATE INDEX b") + + def test_commented_out_ddl_is_dropped(self): + from create_context_graph.ontology import split_cypher_statements + + script = ( + "// CREATE VECTOR INDEX v IF NOT EXISTS\n" + "// OPTIONS { indexConfig: { `vector.dimensions`: 1536 } };\n" + "CREATE INDEX real_one IF NOT EXISTS FOR (n:A) ON (n.name);\n" + ) + statements = split_cypher_statements(script) + assert statements == [ + "CREATE INDEX real_one IF NOT EXISTS FOR (n:A) ON (n.name)" + ] + + @pytest.mark.parametrize("domain_id", ["financial-services", "healthcare", "software-engineering"]) + def test_generated_schema_yields_only_executable_statements(self, domain_id): + from create_context_graph.ontology import ( + generate_cypher_schema, + split_cypher_statements, + ) + + schema = generate_cypher_schema(load_domain(domain_id)) + statements = split_cypher_statements(schema) + assert statements, "schema produced no statements" + for stmt in statements: + first_word = stmt.split(None, 1)[0].upper() + assert first_word in {"CREATE", "DROP", "CALL", "SHOW", "MATCH", "MERGE"}, ( + f"non-executable fragment leaked through: {stmt[:80]!r}" + ) + + def test_previously_skipped_statements_are_recovered(self): + """The old pattern dropped person_name/document_title/document_domain + and the fulltext index for every domain. Pin their recovery.""" + from create_context_graph.ontology import ( + generate_cypher_schema, + split_cypher_statements, + ) + + schema = generate_cypher_schema(load_domain("financial-services")) + statements = split_cypher_statements(schema) + joined = "\n".join(statements) + for name in ("person_name", "document_title", "document_domain", + "document_name_unique", "local_file_fulltext"): + assert name in joined, f"{name} missing from split statements" + + # And prove the OLD pattern really did drop statements (regression doc) + old_style = [ + s.strip() for s in schema.split(";") + if s.strip() and not s.strip().startswith("//") + ] + old_joined = "\n".join(old_style) + assert "person_name" not in old_joined + + +class TestCustomDomainIsolation: + """The autouse conftest fixture must keep tests hermetic against + ~/.create-context-graph/custom-domains/ — a contributor's saved custom + domains previously leaked into every domain-iterating test (the + "football-intelligence" failure reports against v0.13.x).""" + + def test_custom_domains_path_is_isolated_from_home(self): + from pathlib import Path + + from create_context_graph import custom_domain as custom_domain_mod + from create_context_graph import ontology as ontology_mod + + home = Path.home() + for resolver in ( + ontology_mod._get_custom_domains_path, + custom_domain_mod._get_custom_domains_path, + ): + resolved = resolver() + assert not resolved.is_relative_to(home), ( + f"tests are reading the real user directory: {resolved}" + ) + + def test_listed_domains_are_exactly_the_bundled_set(self): + """With isolation active, only bundled domains appear.""" + from create_context_graph.ontology import _get_domains_path + + bundled = { + p.stem for p in _get_domains_path().glob("*.yaml") + if not p.stem.startswith("_") + } + listed = {d["id"] for d in list_available_domains()} + assert listed == bundled diff --git a/tests/test_routes_integration.py b/tests/test_routes_integration.py index 63af7a0..915e392 100644 --- a/tests/test_routes_integration.py +++ b/tests/test_routes_integration.py @@ -481,3 +481,231 @@ def test_gds_communities_works_on_bolt(self, tmp_path): # Returns 200 with an empty list from our mocked GDS assert r.status_code == 200 assert "communities" in r.json() + + +# --------------------------------------------------------------------------- +# v0.14.0 — NAMS cypher dispatch + degraded-memory health reporting +# --------------------------------------------------------------------------- + + +class TestNamsCypherRoute: + """PR #56: /cypher on NAMS goes through execute_cypher (which dispatches + to the NAMS query API) instead of talking to the client inline.""" + + def test_cypher_dispatches_through_execute_cypher(self, tmp_path): + from fastapi.testclient import TestClient + + backend_dir = _scaffold(tmp_path, backend="nams") + client = _fake_client() + app, _, cgc = _import_app(backend_dir, backend="nams", fake_client=client) + + cgc.execute_cypher = AsyncMock(return_value=[{"total": 5}]) + sys.modules["app.context_graph_client"].execute_cypher = cgc.execute_cypher + import app.routes as routes_mod + routes_mod.execute_cypher = cgc.execute_cypher + + with TestClient(app) as tc: + r = tc.post( + "/api/cypher", + json={"query": "MATCH (n) RETURN count(n) AS total", "parameters": {}}, + ) + assert r.status_code == 200 + assert r.json() == {"results": [{"total": 5}]} + + cgc.execute_cypher.assert_awaited_once() + args, kwargs = cgc.execute_cypher.await_args + assert args[0] == "MATCH (n) RETURN count(n) AS total" + assert kwargs.get("collect") is True + + def test_cypher_error_maps_to_400(self, tmp_path): + from fastapi.testclient import TestClient + + backend_dir = _scaffold(tmp_path, backend="nams") + client = _fake_client() + app, _, cgc = _import_app(backend_dir, backend="nams", fake_client=client) + + cgc.execute_cypher = AsyncMock( + side_effect=RuntimeError("NAMS rejected write query") + ) + sys.modules["app.context_graph_client"].execute_cypher = cgc.execute_cypher + import app.routes as routes_mod + routes_mod.execute_cypher = cgc.execute_cypher + + with TestClient(app) as tc: + r = tc.post("/api/cypher", json={"query": "CREATE (n) RETURN n"}) + assert r.status_code == 400 + assert "NAMS rejected" in r.json()["detail"] + + def test_api_routes_return_503_when_client_missing(self, tmp_path): + """PR #56: _require_neo4j must fail fast with 503 when the NAMS + client never connected, instead of letting each adapter blow up.""" + from fastapi.testclient import TestClient + + backend_dir = _scaffold(tmp_path, backend="nams") + app, _, _ = _import_app(backend_dir, backend="nams", fake_client=None) + + with TestClient(app) as tc: + for method, url, payload in [ + ("get", "/api/documents", None), + ("get", "/api/traces", None), + ("post", "/api/cypher", {"query": "RETURN 1"}), + ("post", "/api/search", {"query": "x"}), + ]: + r = tc.get(url) if method == "get" else tc.post(url, json=payload) + assert r.status_code == 503, f"{url} -> {r.status_code}" + assert "NAMS client not connected" in r.json()["detail"] + + # /health never 503s — it reports the degraded state instead + r = tc.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "degraded" + + def test_cypher_on_bolt_still_injects_domain_param(self, tmp_path): + """The PR #56 refactor hoisted params handling — the bolt branch must + keep defaulting the $domain parameter for domain-scoped queries.""" + from fastapi.testclient import TestClient + + backend_dir = _scaffold(tmp_path, backend="bolt") + client = _fake_client() + app, _, cgc = _import_app(backend_dir, backend="bolt", fake_client=client) + + cgc.execute_cypher = AsyncMock(return_value=[]) + sys.modules["app.context_graph_client"].execute_cypher = cgc.execute_cypher + import app.routes as routes_mod + routes_mod.execute_cypher = cgc.execute_cypher + + with TestClient(app) as tc: + r = tc.post("/api/cypher", json={"query": "MATCH (n) RETURN n"}) + assert r.status_code == 200 + + args, _ = cgc.execute_cypher.await_args + assert args[1]["domain"] == "financial-services" + + +class TestBoltHealthMemorySurfacing: + """PR #60: live store_message() failures must surface in /health instead + of the app reporting "ok" while every memory write silently fails.""" + + def test_health_reports_memory_field(self, tmp_path): + from fastapi.testclient import TestClient + + backend_dir = _scaffold(tmp_path, backend="bolt") + client = _fake_client() + app, _, _ = _import_app(backend_dir, backend="bolt", fake_client=client) + + with TestClient(app) as tc: + body = tc.get("/health").json() + assert body["status"] == "ok" + assert body["memory"] is True + assert "memory_error" not in body + + def test_store_failure_degrades_health_and_recovery_clears_it(self, tmp_path): + import asyncio + + from fastapi.testclient import TestClient + + backend_dir = _scaffold(tmp_path, backend="bolt") + client = _fake_client() + app, memory_mod, _ = _import_app(backend_dir, backend="bolt", fake_client=client) + + with TestClient(app) as tc: + # Simulate a live write failure (e.g. wrong NEO4J_DATABASE name) + memory_mod._memory = SimpleNamespace( + store_message=AsyncMock( + side_effect=ConnectionError("database 'neo4j' does not exist") + ) + ) + asyncio.run(memory_mod.store_message("s-1", "user", "hi")) + + body = tc.get("/health").json() + assert body["status"] == "degraded" + assert body["neo4j"] is True # bolt itself is fine + assert body["memory"] is False + assert body["memory_error"] == "network" + assert body["memory_error_detail"] == "ConnectionError" + + # A later successful write clears the error state + memory_mod._memory = SimpleNamespace( + store_message=AsyncMock(return_value={"entities": []}) + ) + asyncio.run(memory_mod.store_message("s-1", "user", "hi again")) + + body = tc.get("/health").json() + assert body["status"] == "ok" + assert body["memory"] is True + assert "memory_error" not in body + + +class TestNamsAdapterCypherPaths: + """v0.14.0 live-service fixes: the live NAMS coerces unknown entity types + to "custom" and rejects empty search queries — the documents and schema + endpoints now enumerate through the cypher API using the scaffold's own + description markers.""" + + def test_documents_enumerates_via_cypher_marker(self, tmp_path): + from fastapi.testclient import TestClient + + backend_dir = _scaffold(tmp_path, backend="nams") + client = _fake_client() + client.query.cypher = AsyncMock(return_value=[ + {"name": "Discharge Note", "description": "Discharge content\n\n_pole_type: OBJECT_"}, + ]) + # Search must NOT be needed when cypher works + client.long_term.search_entities = AsyncMock( + side_effect=AssertionError("search fallback used despite cypher success") + ) + app, _, _ = _import_app(backend_dir, backend="nams", fake_client=client) + + with TestClient(app) as tc: + r = tc.get("/api/documents") + assert r.status_code == 200 + docs = r.json()["documents"] + assert len(docs) == 1 + assert docs[0]["title"] == "Discharge Note" + assert "_pole_type" not in docs[0]["content"] + + def test_documents_falls_back_to_search_with_marker_filter(self, tmp_path): + """When cypher is unavailable, custom-typed entities carrying the + document marker must still be listed (live service stores our OBJECT + docs as type "custom").""" + from fastapi.testclient import TestClient + + backend_dir = _scaffold(tmp_path, backend="nams") + client = _fake_client() + client.query.cypher = AsyncMock(side_effect=RuntimeError("cypher unsupported")) + client.long_term.search_entities = AsyncMock(return_value=[ + SimpleNamespace( + name="Marked Doc", entity_type="custom", + description="Doc body\n\n_pole_type: OBJECT_", + ), + SimpleNamespace( + name="Unmarked Person", entity_type="custom", + description="Just a person description", + ), + ]) + app, _, _ = _import_app(backend_dir, backend="nams", fake_client=client) + + with TestClient(app) as tc: + r = tc.get("/api/documents") + assert r.status_code == 200 + docs = r.json()["documents"] + assert [d["title"] for d in docs] == ["Marked Doc"] + + def test_schema_visualization_aggregates_via_cypher(self, tmp_path): + from fastapi.testclient import TestClient + + backend_dir = _scaffold(tmp_path, backend="nams") + client = _fake_client() + client.query.cypher = AsyncMock(return_value=[ + {"type": "Person", "count": 25}, + {"type": "custom", "count": 71}, + ]) + app, _, _ = _import_app(backend_dir, backend="nams", fake_client=client) + + with TestClient(app) as tc: + r = tc.get("/api/schema/visualization") + assert r.status_code == 200 + body = r.json() + counts = {n["name"]: n["count"] for n in body["nodes"]} + assert counts == {"Person": 25, "custom": 71} + assert body["relationships"] == [] diff --git a/tests/test_wizard.py b/tests/test_wizard.py index a6ad886..cb07559 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -315,3 +315,66 @@ def test_empty_nams_key_aborts(self, monkeypatch): with pytest.raises(SystemExit): run_wizard(self_hosted=False) + + +class TestParseAuraEnv: + """v0.14.0: _parse_aura_env returns (uri, username, password, database).""" + + def _write(self, tmp_path, content): + p = tmp_path / "aura.env" + p.write_text(content) + return str(p) + + def test_four_tuple_with_database(self, tmp_path): + from create_context_graph.wizard import _parse_aura_env + + path = self._write( + tmp_path, + 'NEO4J_URI=neo4j+s://abc.databases.neo4j.io\n' + 'NEO4J_USERNAME=neo4j\n' + 'NEO4J_PASSWORD=pw\n' + 'NEO4J_DATABASE=instance-4f9a\n', + ) + uri, username, password, database = _parse_aura_env(path) + assert uri == "neo4j+s://abc.databases.neo4j.io" + assert username == "neo4j" + assert password == "pw" + assert database == "instance-4f9a" + + def test_database_defaults_to_empty(self, tmp_path): + """Aura downloads without NEO4J_DATABASE must keep deferring to the + SDK default rather than crashing or inventing a name.""" + from create_context_graph.wizard import _parse_aura_env + + path = self._write( + tmp_path, + 'NEO4J_URI=neo4j+s://abc.databases.neo4j.io\nNEO4J_PASSWORD=pw\n', + ) + uri, username, password, database = _parse_aura_env(path) + assert database == "" + assert username == "neo4j" # default when absent + + def test_quoted_database_value_is_unwrapped(self, tmp_path): + from create_context_graph.wizard import _parse_aura_env + + path = self._write( + tmp_path, + 'NEO4J_URI=neo4j+s://abc.databases.neo4j.io\n' + 'NEO4J_PASSWORD=pw\n' + 'NEO4J_DATABASE="quoted-db"\n', + ) + assert _parse_aura_env(path)[3] == "quoted-db" + + def test_missing_uri_aborts(self, tmp_path): + from create_context_graph.wizard import _parse_aura_env + + path = self._write(tmp_path, "NEO4J_PASSWORD=pw\n") + with pytest.raises(SystemExit): + _parse_aura_env(path) + + def test_missing_password_aborts(self, tmp_path): + from create_context_graph.wizard import _parse_aura_env + + path = self._write(tmp_path, "NEO4J_URI=neo4j+s://abc.databases.neo4j.io\n") + with pytest.raises(SystemExit): + _parse_aura_env(path) diff --git a/uv.lock b/uv.lock index a41f7fd..84787f7 100644 --- a/uv.lock +++ b/uv.lock @@ -458,7 +458,7 @@ toml = [ [[package]] name = "create-context-graph" -version = "0.13.1" +version = "0.14.0" source = { editable = "." } dependencies = [ { name = "click" }, @@ -515,6 +515,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "ruff" }, ] generate = [ { name = "anthropic" }, @@ -556,6 +557,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "questionary", specifier = ">=2.0" }, { name = "rich", specifier = ">=13.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.16,<0.17" }, { name = "simple-salesforce", marker = "extra == 'connectors'", specifier = ">=1.12" }, { name = "slack-sdk", marker = "extra == 'connectors'", specifier = ">=3.20" }, ] @@ -2558,6 +2560,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "ruff" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, +] + [[package]] name = "safetensors" version = "0.7.0"