From a78e9424cb3bb522ab2d4dcc4855a9ccaa31ac0a Mon Sep 17 00:00:00 2001 From: Ansh Dawda Date: Wed, 27 May 2026 17:24:08 +0200 Subject: [PATCH 1/3] feat: add ontology-file support for custom domains Add --ontology-file to load a custom domain ontology YAML directly, matching the documented manual custom-domain workflow. Also allow saved custom domains to be loaded by domain id from the user custom-domains directory. --- README.md | 1 + src/create_context_graph/cli.py | 36 ++++++++++++++++++++++++---- src/create_context_graph/ontology.py | 21 ++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ddb6efe..7299519 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,7 @@ Arguments: Options: --domain TEXT Domain ID (e.g., healthcare, gaming) + --ontology-file PATH Path to a custom domain ontology YAML file --framework TEXT Agent framework (strands [default], pydanticai, claude-agent-sdk, openai-agents, langgraph, crewai, google-adk, anthropic-tools) --self-hosted Use self-hosted Neo4j (bolt) instead of NAMS hosted memory --nams-api-key TEXT NAMS API key [env: MEMORY_API_KEY] — obtain at https://memory.neo4jlabs.com diff --git a/src/create_context_graph/cli.py b/src/create_context_graph/cli.py index f34fa7b..6ee4482 100644 --- a/src/create_context_graph/cli.py +++ b/src/create_context_graph/cli.py @@ -133,6 +133,11 @@ def _run_import_preview( type=str, help="Domain ID (e.g., financial-services, healthcare, software-engineering)", ) +@click.option( + "--ontology-file", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + help="Path to a custom domain ontology YAML file", +) @click.option( "--framework", type=click.Choice(SUPPORTED_FRAMEWORKS, case_sensitive=False), @@ -199,6 +204,7 @@ def _run_import_preview( def main( project_name: str | None, domain: str | None, + ontology_file: Path | None, framework: str | None, demo_data: bool, ingest: bool, @@ -315,9 +321,31 @@ def main( console.print() return + domain_source_count = sum( + bool(source) for source in (domain, ontology_file, custom_domain) + ) + if domain_source_count > 1: + console.print( + "[red]Error:[/red] --domain, --ontology-file, and --custom-domain " + "are mutually exclusive." + ) + raise SystemExit(1) + # Handle custom domain generation (non-interactive) custom_domain_yaml = None custom_ontology = None + if ontology_file: + from create_context_graph.ontology import load_domain_from_path + + try: + custom_domain_yaml = ontology_file.read_text() + custom_ontology = load_domain_from_path(ontology_file) + except Exception as e: # noqa: BLE001 — surface YAML/schema errors to the user + console.print(f"[red]Error:[/red] Failed to load ontology file: {e}") + raise SystemExit(1) + + domain = custom_ontology.domain.id + if custom_domain: if not anthropic_api_key: console.print("[red]Error:[/red] --anthropic-api-key is required for custom domain generation.") @@ -368,7 +396,7 @@ def main( raise SystemExit(1) # Auto-generate project name when all required flags are provided but no positional arg - if not project_name and (domain or custom_domain) and framework: + if not project_name and (domain or custom_domain or ontology_file) and framework: domain_part = domain or "custom" project_name = f"{domain_part}-{framework}-app" @@ -376,8 +404,8 @@ def main( import sys if not project_name and not sys.stdin.isatty(): missing = [] - if not domain and not custom_domain: - missing.append("--domain") + if not domain and not custom_domain and not ontology_file: + missing.append("--domain, --ontology-file, or --custom-domain") if not framework: missing.append("--framework") console.print(f"[red]Error:[/red] Non-interactive mode requires: {', '.join(missing or ['--domain and --framework'])}") @@ -388,7 +416,7 @@ def main( # If all required args are provided (and a backend is determinable), skip wizard. # In non-interactive mode with default NAMS backend, --nams-api-key (or # MEMORY_API_KEY env) must be set unless --self-hosted is specified. - if project_name and (domain or custom_domain) and (framework or DEFAULT_FRAMEWORK): + if project_name and (domain or custom_domain or ontology_file) and (framework or DEFAULT_FRAMEWORK): # Skip the credential gate during --dry-run so users can preview a # scaffold without first signing up for a NAMS API key. if not dry_run and memory_backend_resolved == "nams" and not nams_api_key: diff --git a/src/create_context_graph/ontology.py b/src/create_context_graph/ontology.py index abad3b0..aef56c8 100644 --- a/src/create_context_graph/ontology.py +++ b/src/create_context_graph/ontology.py @@ -241,6 +241,27 @@ def load_domain(domain_id: str) -> DomainOntology: domains_dir = _get_domains_path() domain_path = domains_dir / f"{domain_id}.yaml" + if not domain_path.exists(): + custom_dir = _get_custom_domains_path() + custom_path = custom_dir / f"{domain_id}.yaml" + if custom_path.exists(): + domain_path = custom_path + elif custom_dir.exists(): + for path in sorted(custom_dir.glob("*.yaml")): + if path.stem.startswith("_"): + continue + try: + with open(path) as f: + data = yaml.safe_load(f) + except Exception: + continue + if ( + isinstance(data, dict) + and data.get("domain", {}).get("id") == domain_id + ): + domain_path = path + break + if not domain_path.exists(): raise FileNotFoundError(f"Domain ontology not found: {domain_id}") From 1d1d3846dd5ab414d364d2a790f686e2be6eb642 Mon Sep 17 00:00:00 2001 From: Ansh Dawda Date: Wed, 27 May 2026 22:01:30 +0200 Subject: [PATCH 2/3] feat: add tests for ontology file handling and custom domain loading --- tests/test_cli.py | 49 +++++++++++++++++++++++++++++++++++++ tests/test_custom_domain.py | 16 ++++++++++++ 2 files changed, 65 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2c06da7..bcef4c0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,6 +15,7 @@ """Integration tests for the CLI module.""" import json +from pathlib import Path import pytest @@ -76,6 +77,54 @@ def test_scaffold_with_demo_data(self, runner, tmp_path): data = json.loads(fixture.read_text()) assert len(data["entities"]) > 0 + def test_scaffold_with_ontology_file(self, runner, tmp_path): + ontology_file = Path("src/create_context_graph/domains/healthcare.yaml") + out = tmp_path / "ontology-app" + + result = runner.invoke(main, [ + "ontology-app", + "--ontology-file", str(ontology_file), + "--framework", "pydanticai", + "--output-dir", str(out), + ]) + + assert result.exit_code == 0, result.output + assert (out / "backend" / "app" / "main.py").exists() + assert (out / "frontend" / "package.json").exists() + assert ( + (out / "data" / "ontology.yaml").read_text() + == ontology_file.read_text() + ) + + def test_ontology_file_auto_generates_project_name(self, runner, tmp_path): + ontology_file = Path("src/create_context_graph/domains/healthcare.yaml") + out = tmp_path / "dry-run-app" + + result = runner.invoke(main, [ + "--ontology-file", str(ontology_file), + "--framework", "pydanticai", + "--output-dir", str(out), + "--dry-run", + ]) + + assert result.exit_code == 0, result.output + assert "healthcare-pydanticai-app" in result.output + + def test_ontology_file_is_mutually_exclusive_with_domain(self, runner, tmp_path): + ontology_file = Path("src/create_context_graph/domains/healthcare.yaml") + out = tmp_path / "my-app" + + result = runner.invoke(main, [ + "my-app", + "--domain", "healthcare", + "--ontology-file", str(ontology_file), + "--framework", "pydanticai", + "--output-dir", str(out), + ]) + + assert result.exit_code == 1 + assert "mutually exclusive" in result.output + def test_invalid_domain(self, runner, tmp_path): out = tmp_path / "my-app" result = runner.invoke(main, [ diff --git a/tests/test_custom_domain.py b/tests/test_custom_domain.py index c0ef05d..f6a06d8 100644 --- a/tests/test_custom_domain.py +++ b/tests/test_custom_domain.py @@ -32,6 +32,7 @@ from create_context_graph.ontology import ( DomainOntology, list_available_domains, + load_domain, load_domain_from_yaml_string, ) @@ -479,3 +480,18 @@ def test_list_includes_saved_custom(self, tmp_path): domains = list_available_domains() ids = [d["id"] for d in domains] assert "test-domain" in ids + + def test_load_domain_finds_saved_custom_by_domain_id(self, tmp_path): + """Saved custom domains can be loaded by the ID declared in YAML.""" + custom_dir = tmp_path / "custom-domains" + custom_dir.mkdir() + (custom_dir / "my-custom.yaml").write_text(VALID_DOMAIN_YAML) + + with patch( + "create_context_graph.ontology._get_custom_domains_path", + return_value=custom_dir, + ): + ontology = load_domain("test-domain") + + assert ontology.domain.id == "test-domain" + assert ontology.domain.name == "Test Domain" From c3c8bf0bdb6b354129a16d47e26977409f761c54 Mon Sep 17 00:00:00 2001 From: Ansh Dawda Date: Thu, 28 May 2026 12:50:51 +0200 Subject: [PATCH 3/3] feat: improve error handling for ontology file loading and enforce mutual exclusivity in CLI --- src/create_context_graph/cli.py | 11 +++++------ src/create_context_graph/ontology.py | 19 +++++++++++++------ tests/test_cli.py | 17 ++++++++++------- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/create_context_graph/cli.py b/src/create_context_graph/cli.py index 6ee4482..16b10f8 100644 --- a/src/create_context_graph/cli.py +++ b/src/create_context_graph/cli.py @@ -325,11 +325,9 @@ def main( bool(source) for source in (domain, ontology_file, custom_domain) ) if domain_source_count > 1: - console.print( - "[red]Error:[/red] --domain, --ontology-file, and --custom-domain " - "are mutually exclusive." + raise click.UsageError( + "--domain, --ontology-file, and --custom-domain are mutually exclusive." ) - raise SystemExit(1) # Handle custom domain generation (non-interactive) custom_domain_yaml = None @@ -341,8 +339,9 @@ def main( custom_domain_yaml = ontology_file.read_text() custom_ontology = load_domain_from_path(ontology_file) except Exception as e: # noqa: BLE001 — surface YAML/schema errors to the user - console.print(f"[red]Error:[/red] Failed to load ontology file: {e}") - raise SystemExit(1) + raise click.ClickException( + f"Failed to load ontology file {ontology_file}: {e}" + ) from e domain = custom_ontology.domain.id diff --git a/src/create_context_graph/ontology.py b/src/create_context_graph/ontology.py index aef56c8..6f07961 100644 --- a/src/create_context_graph/ontology.py +++ b/src/create_context_graph/ontology.py @@ -17,11 +17,14 @@ from __future__ import annotations from importlib.resources import files +import logging from pathlib import Path import yaml from pydantic import BaseModel, Field +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Pydantic models for the ontology YAML schema @@ -240,6 +243,7 @@ def load_domain(domain_id: str) -> DomainOntology: """Load a domain ontology by ID, merging with base definitions.""" domains_dir = _get_domains_path() domain_path = domains_dir / f"{domain_id}.yaml" + data = None if not domain_path.exists(): custom_dir = _get_custom_domains_path() @@ -252,21 +256,24 @@ def load_domain(domain_id: str) -> DomainOntology: continue try: with open(path) as f: - data = yaml.safe_load(f) - except Exception: + candidate_data = yaml.safe_load(f) + except (OSError, yaml.YAMLError) as exc: + logger.debug("Skipping invalid custom domain YAML %s: %s", path, exc) continue if ( - isinstance(data, dict) - and data.get("domain", {}).get("id") == domain_id + isinstance(candidate_data, dict) + and candidate_data.get("domain", {}).get("id") == domain_id ): domain_path = path + data = candidate_data break if not domain_path.exists(): raise FileNotFoundError(f"Domain ontology not found: {domain_id}") - with open(domain_path) as f: - data = yaml.safe_load(f) + if data is None: + with open(domain_path) as f: + data = yaml.safe_load(f) # Merge base if domain declares inheritance if data.get("inherits") == "_base" or data.get("domain", {}).get("inherits") == "_base": diff --git a/tests/test_cli.py b/tests/test_cli.py index bcef4c0..a783c71 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,7 @@ from pathlib import Path import pytest +import yaml from create_context_graph.cli import main @@ -25,6 +26,9 @@ # The ``runner`` and ``nams_runner`` fixtures live in tests/conftest.py so they # can be shared with test_matrix.py, test_performance.py, and others. +REPO_ROOT = Path(__file__).resolve().parents[1] +HEALTHCARE_ONTOLOGY = REPO_ROOT / "src" / "create_context_graph" / "domains" / "healthcare.yaml" + class TestListDomains: def test_list_domains(self, runner): @@ -78,7 +82,7 @@ def test_scaffold_with_demo_data(self, runner, tmp_path): assert len(data["entities"]) > 0 def test_scaffold_with_ontology_file(self, runner, tmp_path): - ontology_file = Path("src/create_context_graph/domains/healthcare.yaml") + ontology_file = HEALTHCARE_ONTOLOGY out = tmp_path / "ontology-app" result = runner.invoke(main, [ @@ -91,13 +95,12 @@ def test_scaffold_with_ontology_file(self, runner, tmp_path): assert result.exit_code == 0, result.output assert (out / "backend" / "app" / "main.py").exists() assert (out / "frontend" / "package.json").exists() - assert ( - (out / "data" / "ontology.yaml").read_text() - == ontology_file.read_text() + assert yaml.safe_load((out / "data" / "ontology.yaml").read_text()) == yaml.safe_load( + ontology_file.read_text() ) def test_ontology_file_auto_generates_project_name(self, runner, tmp_path): - ontology_file = Path("src/create_context_graph/domains/healthcare.yaml") + ontology_file = HEALTHCARE_ONTOLOGY out = tmp_path / "dry-run-app" result = runner.invoke(main, [ @@ -111,7 +114,7 @@ def test_ontology_file_auto_generates_project_name(self, runner, tmp_path): assert "healthcare-pydanticai-app" in result.output def test_ontology_file_is_mutually_exclusive_with_domain(self, runner, tmp_path): - ontology_file = Path("src/create_context_graph/domains/healthcare.yaml") + ontology_file = HEALTHCARE_ONTOLOGY out = tmp_path / "my-app" result = runner.invoke(main, [ @@ -122,7 +125,7 @@ def test_ontology_file_is_mutually_exclusive_with_domain(self, runner, tmp_path) "--output-dir", str(out), ]) - assert result.exit_code == 1 + assert result.exit_code == 2 assert "mutually exclusive" in result.output def test_invalid_domain(self, runner, tmp_path):