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..16b10f8 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,30 @@ def main( console.print() return + domain_source_count = sum( + bool(source) for source in (domain, ontology_file, custom_domain) + ) + if domain_source_count > 1: + raise click.UsageError( + "--domain, --ontology-file, and --custom-domain are mutually exclusive." + ) + # 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 + raise click.ClickException( + f"Failed to load ontology file {ontology_file}: {e}" + ) from e + + 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 +395,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 +403,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 +415,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..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,12 +243,37 @@ 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() + 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: + 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(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 2c06da7..a783c71 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,8 +15,10 @@ """Integration tests for the CLI module.""" import json +from pathlib import Path import pytest +import yaml from create_context_graph.cli import main @@ -24,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): @@ -76,6 +81,53 @@ 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 = HEALTHCARE_ONTOLOGY + 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 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 = HEALTHCARE_ONTOLOGY + 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 = HEALTHCARE_ONTOLOGY + 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 == 2 + 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"