From eb5111f05513512a1368716df0b3142808a38100 Mon Sep 17 00:00:00 2001 From: tomd Date: Sun, 29 Mar 2026 01:06:34 +0100 Subject: [PATCH 1/9] Add local model inference support and improve Neo4j troubleshooting - Add support for localhost model inference (local LLM servers) - Extend CLI and wizard to accept local model endpoints - Add Google/Gemini API key support for google-adk framework - Add --demo shortcut flag for quick demo setup - Improve README with comprehensive Neo4j authentication troubleshooting --- README.md | 23 ++ src/create_context_graph/cli.py | 21 +- src/create_context_graph/config.py | 1 + src/create_context_graph/custom_domain.py | 3 +- src/create_context_graph/generator.py | 23 +- src/create_context_graph/ontology.py | 8 +- src/create_context_graph/renderer.py | 7 +- .../agents/anthropic_tools/agent.py.j2 | 7 + .../agents/claude_agent_sdk/agent.py.j2 | 6 +- .../backend/agents/langgraph/agent.py.j2 | 9 +- .../backend/agents/pydanticai/agent.py.j2 | 7 + .../backend/agents/strands/agent.py.j2 | 7 + .../templates/backend/shared/config.py.j2 | 1 + .../templates/base/docker-compose.prod.yml.j2 | 1 + .../templates/base/dot_env.j2 | 2 + .../templates/base/dot_env_example.j2 | 1 + src/create_context_graph/wizard.py | 303 +++++++++++------- 17 files changed, 304 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 9dd4e2a..e3aedde 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,29 @@ make start # Start backend (port 8000) + frontend (port 3000) - **Backend API:** http://localhost:8000/docs — FastAPI auto-generated docs - **Neo4j Browser:** http://localhost:7474 — Query the graph directly +## Known Issues + +### Neo4j Authentication + +If Neo4j connection fails or the password is lost, reset the database: + +```bash +make neo4j-stop && rm -rf ~/.local/share/neo4j-local/default && make neo4j-start +cat /tmp/neo4j-local.log # View generated password +``` + +**Connection Details:** +- URI: `bolt://localhost:7687` +- Username: `neo4j` +- Database: `neo4j` +- Password: Generated automatically (check logs) + +### Common Problems + +- **Port conflicts:** Ensure ports 7687 and 7474 are available +- **Permissions:** Verify write access to `~/.local/share/neo4j-local/` +- **Dependencies:** `neo4j-local` requires Node.js; Docker mode requires Docker Desktop + ## Supported Domains 22 industry domains, each with a purpose-built ontology, sample data, agent tools, and demo scenarios: diff --git a/src/create_context_graph/cli.py b/src/create_context_graph/cli.py index 1f34f49..db64284 100644 --- a/src/create_context_graph/cli.py +++ b/src/create_context_graph/cli.py @@ -50,6 +50,7 @@ @click.option("--neo4j-aura-env", type=click.Path(exists=True), help="Path to Neo4j Aura .env file with credentials") @click.option("--neo4j-local", is_flag=True, help="Use @johnymontana/neo4j-local for local Neo4j (no Docker)") @click.option("--anthropic-api-key", envvar="ANTHROPIC_API_KEY", help="Anthropic API key for LLM generation") +@click.option("--anthropic-base-url", envvar="ANTHROPIC_BASE_URL", help="Anthropic-compatible API base URL (e.g., http://127.0.0.1:8082)") @click.option("--openai-api-key", envvar="OPENAI_API_KEY", help="OpenAI API key for LLM generation") @click.option("--google-api-key", envvar="GOOGLE_API_KEY", help="Google/Gemini API key (required for google-adk framework)") @click.option("--custom-domain", type=str, help="Natural language description for custom domain generation (requires --anthropic-api-key)") @@ -73,6 +74,7 @@ def main( neo4j_aura_env: str | None, neo4j_local: bool, anthropic_api_key: str | None, + anthropic_base_url: str | None, openai_api_key: str | None, google_api_key: str | None, custom_domain: str | None, @@ -124,7 +126,7 @@ def main( console.print("[bold]Generating custom domain ontology...[/bold]") try: custom_ontology, custom_domain_yaml = generate_custom_domain( - custom_domain, anthropic_api_key + custom_domain, anthropic_api_key, base_url=anthropic_base_url ) except ValueError as e: console.print(f"[red]Error:[/red] {e}") @@ -169,6 +171,7 @@ def main( neo4j_password=neo4j_password, neo4j_type=neo4j_type_resolved, anthropic_api_key=anthropic_api_key, + anthropic_base_url=anthropic_base_url, openai_api_key=openai_api_key, google_api_key=google_api_key, generate_data=demo_data, @@ -185,7 +188,21 @@ def main( # Launch interactive wizard from create_context_graph.wizard import run_wizard - config = run_wizard() + config = run_wizard( + project_name=project_name, + domain=domain, + framework=framework, + anthropic_api_key=anthropic_api_key, + anthropic_base_url=anthropic_base_url, + openai_api_key=openai_api_key, + connector=connector, + demo_data=demo_data, + neo4j_uri=neo4j_uri, + neo4j_username=neo4j_username, + neo4j_password=neo4j_password, + neo4j_local=neo4j_local, + neo4j_aura_env=neo4j_aura_env, + ) # Resolve output directory out = Path(output_dir) if output_dir else Path.cwd() / config.project_slug diff --git a/src/create_context_graph/config.py b/src/create_context_graph/config.py index 19ff7b9..1098dbf 100644 --- a/src/create_context_graph/config.py +++ b/src/create_context_graph/config.py @@ -73,6 +73,7 @@ class ProjectConfig(BaseModel): neo4j_password: str = Field(default="password") neo4j_type: Literal["docker", "existing", "aura", "local"] = Field(default="docker") anthropic_api_key: str | None = Field(default=None) + anthropic_base_url: str | None = Field(default=None) openai_api_key: str | None = Field(default=None) google_api_key: str | None = Field(default=None) generate_data: bool = Field(default=False) diff --git a/src/create_context_graph/custom_domain.py b/src/create_context_graph/custom_domain.py index f4d77ef..768ad7c 100644 --- a/src/create_context_graph/custom_domain.py +++ b/src/create_context_graph/custom_domain.py @@ -211,13 +211,14 @@ def generate_custom_domain( api_key: str, provider: str = "anthropic", max_retries: int = 3, + base_url: str | None = None, ) -> tuple[DomainOntology, str]: """Generate a complete domain ontology from a natural language description. Returns (DomainOntology, raw_yaml_string) on success. Raises ValueError if generation fails after max_retries. """ - client, resolved_provider = _get_llm_client(api_key, provider) + client, resolved_provider = _get_llm_client(api_key, provider, base_url) if client is None: raise ValueError( "Could not initialize LLM client. Install 'anthropic' or 'openai' package." diff --git a/src/create_context_graph/generator.py b/src/create_context_graph/generator.py index 087682e..b5c7c6a 100644 --- a/src/create_context_graph/generator.py +++ b/src/create_context_graph/generator.py @@ -42,12 +42,15 @@ # --------------------------------------------------------------------------- -def _get_llm_client(api_key: str, provider: str = "anthropic"): +def _get_llm_client(api_key: str, provider: str = "anthropic", base_url: str | None = None): """Get an LLM client for generation.""" if provider == "anthropic": try: import anthropic - return anthropic.Anthropic(api_key=api_key), "anthropic" + client_kwargs = {"api_key": api_key} + if base_url: + client_kwargs["base_url"] = base_url + return anthropic.Anthropic(**client_kwargs), "anthropic" except ImportError: pass @@ -70,7 +73,21 @@ def _llm_generate(client, provider: str, prompt: str, system: str = "") -> str: system=system, messages=[{"role": "user", "content": prompt}], ) - return response.content[0].text + # Handle both text content and thinking blocks + content = response.content[0] + if hasattr(content, 'text'): + return content.text + elif hasattr(content, 'thinking'): + # Find the next text block after thinking + for block in response.content: + if hasattr(block, 'text'): + return block.text + # Fallback: extract text from all blocks + text_parts = [] + for block in response.content: + if hasattr(block, 'text'): + text_parts.append(block.text) + return ''.join(text_parts) elif provider == "openai": messages = [] if system: diff --git a/src/create_context_graph/ontology.py b/src/create_context_graph/ontology.py index b7348b8..76ddb91 100644 --- a/src/create_context_graph/ontology.py +++ b/src/create_context_graph/ontology.py @@ -238,9 +238,15 @@ def _merge_base(base: dict, domain_data: dict) -> dict: def load_domain(domain_id: str) -> DomainOntology: """Load a domain ontology by ID, merging with base definitions.""" + # Check main domains directory first, then custom domains domains_dir = _get_domains_path() domain_path = domains_dir / f"{domain_id}.yaml" - + + if not domain_path.exists(): + # Try custom domains directory + custom_domains_dir = _get_custom_domains_path() + domain_path = custom_domains_dir / f"{domain_id}.yaml" + if not domain_path.exists(): raise FileNotFoundError(f"Domain ontology not found: {domain_id}") diff --git a/src/create_context_graph/renderer.py b/src/create_context_graph/renderer.py index 510e2b1..c7b4eab 100644 --- a/src/create_context_graph/renderer.py +++ b/src/create_context_graph/renderer.py @@ -109,6 +109,7 @@ def _context(self) -> dict: "neo4j_password": self.config.neo4j_password, "neo4j_type": self.config.neo4j_type, "anthropic_api_key": self.config.anthropic_api_key or "", + "anthropic_base_url": self.config.anthropic_base_url or "", "openai_api_key": self.config.openai_api_key or "", "google_api_key": self.config.google_api_key or "", "system_prompt": self.ontology.system_prompt, @@ -203,7 +204,7 @@ def _render_backend(self, backend_dir: Path, ctx: dict) -> None: agent_template = f"backend/agents/{fw_key}/agent.py.j2" try: self._render_template(agent_template, backend_dir / "app" / "agent.py", ctx) - except Exception: + except Exception as e: # Fallback: render a minimal agent stub self._render_template( "backend/shared/agent_stub.py.j2", @@ -300,6 +301,8 @@ def _render_cypher(self, cypher_dir: Path, ctx: dict) -> None: def _render_data(self, data_dir: Path, ctx: dict) -> None: """Copy ontology and create data directory structure.""" + from create_context_graph.ontology import _get_domains_path + data_dir.mkdir(parents=True, exist_ok=True) (data_dir / "documents").mkdir(exist_ok=True) @@ -308,8 +311,6 @@ def _render_data(self, data_dir: Path, ctx: dict) -> None: # Write custom domain YAML directly (data_dir / "ontology.yaml").write_text(self.config.custom_domain_yaml) else: - from create_context_graph.ontology import _get_domains_path - domain_yaml = _get_domains_path() / f"{self.config.domain}.yaml" if domain_yaml.exists(): shutil.copy2(domain_yaml, data_dir / "ontology.yaml") diff --git a/src/create_context_graph/templates/backend/agents/anthropic_tools/agent.py.j2 b/src/create_context_graph/templates/backend/agents/anthropic_tools/agent.py.j2 index 2dbbacc..404fdfb 100644 --- a/src/create_context_graph/templates/backend/agents/anthropic_tools/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/anthropic_tools/agent.py.j2 @@ -19,6 +19,13 @@ if not os.environ.get("ANTHROPIC_API_KEY"): _key = dotenv_values("../.env").get("ANTHROPIC_API_KEY", "") if _key: os.environ["ANTHROPIC_API_KEY"] = _key + +# Set ANTHROPIC_BASE_URL if configured +{% endraw %} +{% if anthropic_base_url %} +os.environ["ANTHROPIC_BASE_URL"] = "{{ anthropic_base_url }}" +{% endif %} +{% raw %} {% endraw %} import anthropic diff --git a/src/create_context_graph/templates/backend/agents/claude_agent_sdk/agent.py.j2 b/src/create_context_graph/templates/backend/agents/claude_agent_sdk/agent.py.j2 index 998de79..2125eff 100644 --- a/src/create_context_graph/templates/backend/agents/claude_agent_sdk/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/claude_agent_sdk/agent.py.j2 @@ -21,7 +21,11 @@ IMPORTANT: You MUST use the available tools to query the knowledge graph before CRITICAL: Call tools DIRECTLY without any introductory text. Do NOT say "I'll search for..." or "Let me look up..." before calling a tool — just call the tool immediately. Only generate text AFTER you have received the tool results and are ready to provide your final answer.""" -client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) +client_kwargs = {"api_key": settings.anthropic_api_key} +{% if anthropic_base_url %} +client_kwargs["base_url"] = "{{ anthropic_base_url }}" +{% endif %} +client = anthropic.AsyncAnthropic(**client_kwargs) # --------------------------------------------------------------------------- # Tool definitions for {{ domain.name }} diff --git a/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 b/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 index 15f9e25..9cf04f5 100644 --- a/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 @@ -5,6 +5,7 @@ from __future__ import annotations import json import uuid +import langgraph from langchain_anthropic import ChatAnthropic from langchain_core.tools import tool from langgraph.prebuilt import create_react_agent @@ -41,7 +42,6 @@ async def {{ tool.name }}({% for param in tool.parameters %}{{ param.name }}: {{ {% endfor %} -{% raw %} @tool async def run_cypher(query: str, parameters: str = "{}") -> str: """Execute a read-only Cypher query against the knowledge graph.""" @@ -56,13 +56,11 @@ async def run_cypher(query: str, parameters: str = "{}") -> str: except Exception as e: return json.dumps({"error": f"Cypher query failed: {e}"}) - @tool async def get_graph_schema() -> str: """Get the knowledge graph schema (node labels and relationship types).""" result = await get_schema() return json.dumps(result, default=str) -{% endraw %} TOOLS = [ {% for tool in agent_tools %} @@ -72,10 +70,12 @@ TOOLS = [ get_graph_schema, ] -{% raw %} model = ChatAnthropic( model="claude-sonnet-4-20250514", api_key=settings.anthropic_api_key, +{% if anthropic_base_url %} + base_url="{{ anthropic_base_url }}", +{% endif %} ) graph = create_react_agent(model, TOOLS, prompt=SYSTEM_PROMPT) @@ -178,4 +178,3 @@ async def handle_message_stream(message: str, session_id: str | None = None) -> "session_id": session_id, "graph_data": None, } -{% endraw %} diff --git a/src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2 b/src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2 index 58471f6..ba531eb 100644 --- a/src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2 @@ -20,6 +20,13 @@ if not os.environ.get("ANTHROPIC_API_KEY"): _key = dotenv_values("../.env").get("ANTHROPIC_API_KEY", "") if _key: os.environ["ANTHROPIC_API_KEY"] = _key + +# Set ANTHROPIC_BASE_URL if configured +{% endraw %} +{% if anthropic_base_url %} +os.environ["ANTHROPIC_BASE_URL"] = "{{ anthropic_base_url }}" +{% endif %} +{% raw %} {% endraw %} from pydantic_ai import Agent, RunContext diff --git a/src/create_context_graph/templates/backend/agents/strands/agent.py.j2 b/src/create_context_graph/templates/backend/agents/strands/agent.py.j2 index aeca983..ebfabe0 100644 --- a/src/create_context_graph/templates/backend/agents/strands/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/strands/agent.py.j2 @@ -27,6 +27,13 @@ if not os.environ.get("ANTHROPIC_API_KEY"): _key = dotenv_values("../.env").get("ANTHROPIC_API_KEY", "") if _key: os.environ["ANTHROPIC_API_KEY"] = _key + +# Set ANTHROPIC_BASE_URL if configured +{% endraw %} +{% if anthropic_base_url %} +os.environ["ANTHROPIC_BASE_URL"] = "{{ anthropic_base_url }}" +{% endif %} +{% raw %} {% endraw %} diff --git a/src/create_context_graph/templates/backend/shared/config.py.j2 b/src/create_context_graph/templates/backend/shared/config.py.j2 index 0bde5e6..dd885ca 100644 --- a/src/create_context_graph/templates/backend/shared/config.py.j2 +++ b/src/create_context_graph/templates/backend/shared/config.py.j2 @@ -10,6 +10,7 @@ class Settings(BaseSettings): neo4j_username: str = "{{ neo4j_username }}" neo4j_password: str = "{{ neo4j_password }}" anthropic_api_key: str = "" + anthropic_base_url: str = "" openai_api_key: str = "" domain_id: str = "{{ domain.id }}" backend_port: int = 8000 diff --git a/src/create_context_graph/templates/base/docker-compose.prod.yml.j2 b/src/create_context_graph/templates/base/docker-compose.prod.yml.j2 index bc065d5..c46b7c4 100644 --- a/src/create_context_graph/templates/base/docker-compose.prod.yml.j2 +++ b/src/create_context_graph/templates/base/docker-compose.prod.yml.j2 @@ -33,6 +33,7 @@ services: NEO4J_PASSWORD: ${NEO4J_PASSWORD:-password} {% if 'anthropic' in framework or framework == 'pydanticai' or framework == 'claude-agent-sdk' %} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} + ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL} {% endif %} {% if 'openai' in framework or framework == 'langgraph' %} OPENAI_API_KEY: ${OPENAI_API_KEY} diff --git a/src/create_context_graph/templates/base/dot_env.j2 b/src/create_context_graph/templates/base/dot_env.j2 index c6c3b87..e910f2c 100644 --- a/src/create_context_graph/templates/base/dot_env.j2 +++ b/src/create_context_graph/templates/base/dot_env.j2 @@ -8,6 +8,8 @@ NEO4J_PASSWORD={{ neo4j_password }} # LLM API Keys ANTHROPIC_API_KEY={{ anthropic_api_key }} +{% if anthropic_base_url %}ANTHROPIC_BASE_URL={{ anthropic_base_url }} +{% endif %} OPENAI_API_KEY={{ openai_api_key }} GOOGLE_API_KEY={{ google_api_key }} 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 f1cbf09..3cf1573 100644 --- a/src/create_context_graph/templates/base/dot_env_example.j2 +++ b/src/create_context_graph/templates/base/dot_env_example.j2 @@ -12,6 +12,7 @@ NEO4J_PASSWORD=your-password-here # LLM API Keys ANTHROPIC_API_KEY=your-anthropic-key-here +# ANTHROPIC_BASE_URL=http://127.0.0.1:8082 # Override default Anthropic API endpoint OPENAI_API_KEY=your-openai-key-here GOOGLE_API_KEY=your-google-api-key-here # Required for google-adk framework # ANTHROPIC_MODEL=claude-sonnet-4-20250514 # Override default model for Claude Agent SDK diff --git a/src/create_context_graph/wizard.py b/src/create_context_graph/wizard.py index a9237b8..76c7256 100644 --- a/src/create_context_graph/wizard.py +++ b/src/create_context_graph/wizard.py @@ -74,48 +74,74 @@ def _banner() -> None: ) -def run_wizard() -> ProjectConfig: +def run_wizard( + project_name: str | None = None, + domain: str | None = None, + framework: str | None = None, + anthropic_api_key: str | None = None, + anthropic_base_url: str | None = None, + openai_api_key: str | None = None, + connector: tuple[str, ...] = (), + demo_data: bool = False, + neo4j_uri: str | None = None, + neo4j_username: str | None = None, + neo4j_password: str | None = None, + neo4j_local: bool = False, + neo4j_aura_env: str | None = None, +) -> ProjectConfig: """Run the interactive wizard and return a ProjectConfig.""" _banner() # Step 1: Project name - project_name = questionary.text( - "What is your project name?", - default="my-context-graph", - ).ask() - if not project_name: - raise SystemExit("Aborted.") + if project_name: + # Use provided project name + pass + else: + project_name = questionary.text( + "What is your project name?", + default="my-context-graph", + ).ask() + if not project_name: + raise SystemExit("Aborted.") # Step 2: Data source - data_source = questionary.select( - "How would you like to populate your context graph?", - choices=[ - questionary.Choice("Generate demo data (synthetic documents & entities)", value="demo"), - questionary.Choice("Connect to SaaS services (Gmail, Slack, Jira, etc.)", value="saas"), - ], - ).ask() - if not data_source: - raise SystemExit("Aborted.") + if demo_data or connector: + # Use provided data source + data_source = "saas" if connector else ("demo" if demo_data else "none") + else: + data_source = questionary.select( + "How would you like to populate your context graph?", + choices=[ + questionary.Choice("Generate demo data (synthetic documents & entities)", value="demo"), + questionary.Choice("Connect to SaaS services (Gmail, Slack, Jira, etc.)", value="saas"), + ], + ).ask() + if not data_source: + raise SystemExit("Aborted.") # Step 2b: SaaS connector selection (if SaaS data source) selected_connectors: list[str] = [] saas_credentials: dict[str, dict[str, str]] = {} if data_source == "saas": - from create_context_graph.connectors import list_connectors, get_connector - from create_context_graph.connectors.oauth import check_gws_cli, install_gws_cli - - available = list_connectors() - connector_choices = [ - questionary.Choice(f"{c['name']} — {c['description']}", value=c["id"]) - for c in available - ] - - selected_connectors = questionary.checkbox( - "Select services to connect:", - choices=connector_choices, - ).ask() - if not selected_connectors: - raise SystemExit("Aborted. Select at least one connector.") + if connector: + # Use provided connectors + selected_connectors = list(connector) + else: + from create_context_graph.connectors import list_connectors, get_connector + from create_context_graph.connectors.oauth import check_gws_cli, install_gws_cli + + available = list_connectors() + connector_choices = [ + questionary.Choice(f"{c['name']} — {c['description']}", value=c["id"]) + for c in available + ] + + selected_connectors = questionary.checkbox( + "Select services to connect:", + choices=connector_choices, + ).ask() + if not selected_connectors: + raise SystemExit("Aborted. Select at least one connector.") # Check for Google Workspace CLI for Gmail/GCal google_connectors = {"gmail", "gcal"} @@ -152,22 +178,25 @@ def run_wizard() -> ProjectConfig: saas_credentials[conn_id] = creds # Step 3: Domain selection - domains = list_available_domains() - domain_choices = [ - questionary.Choice(d["name"], value=d["id"]) for d in domains - ] - domain_choices.append(questionary.Choice("Custom (describe your domain)", value="custom")) - - domain = questionary.select( - "Select your industry domain:", - choices=domain_choices, - ).ask() - if not domain: - raise SystemExit("Aborted.") + if domain: + # Use provided domain + pass + else: + domains = list_available_domains() + domain_choices = [ + questionary.Choice(d["name"], value=d["id"]) for d in domains + ] + domain_choices.append(questionary.Choice("Custom (describe your domain)", value="custom")) + + domain = questionary.select( + "Select your industry domain:", + choices=domain_choices, + ).ask() + if not domain: + raise SystemExit("Aborted.") custom_domain_yaml = None custom_ontology = None - anthropic_api_key = None if domain == "custom": # Collect domain description domain_description = questionary.text( @@ -177,12 +206,26 @@ def run_wizard() -> ProjectConfig: raise SystemExit("Aborted.") # Need an API key for LLM generation - custom_api_key = questionary.password( - "Anthropic API key (required for custom domain generation):", - ).ask() - if not custom_api_key: - console.print("[red]An API key is required for custom domain generation.[/red]") - raise SystemExit("API key required.") + if anthropic_api_key: + # Use provided API key + custom_api_key = anthropic_api_key + else: + custom_api_key = questionary.password( + "Anthropic API key (required for custom domain generation):", + ).ask() + if not custom_api_key: + console.print("[red]An API key is required for custom domain generation.[/red]") + raise SystemExit("API key required.") + + # Ask for custom base URL + if anthropic_base_url: + # Use provided base URL + custom_base_url = anthropic_base_url + else: + custom_base_url = questionary.text( + "Anthropic-compatible API base URL (Enter to use default):", + default="", + ).ask() # Generate the domain from create_context_graph.custom_domain import ( @@ -195,7 +238,7 @@ def run_wizard() -> ProjectConfig: with console.status("[bold cyan]Generating custom domain ontology..."): try: custom_ontology, custom_domain_yaml = generate_custom_domain( - domain_description, custom_api_key + domain_description, custom_api_key, base_url=custom_base_url ) except ValueError as e: console.print(f"[red]Generation failed: {e}[/red]") @@ -239,84 +282,122 @@ def run_wizard() -> ProjectConfig: anthropic_api_key = custom_api_key # Step 4: Agent framework - framework_choices = [ - questionary.Choice(FRAMEWORK_DISPLAY_NAMES[fw], value=fw) - for fw in SUPPORTED_FRAMEWORKS - ] - framework = questionary.select( - "Select your agent framework:", - choices=framework_choices, - ).ask() - if not framework: - raise SystemExit("Aborted.") + if framework: + # Use provided framework + pass + else: + framework_choices = [ + questionary.Choice(FRAMEWORK_DISPLAY_NAMES[fw], value=fw) + for fw in SUPPORTED_FRAMEWORKS + ] + framework = questionary.select( + "Select your agent framework:", + choices=framework_choices, + ).ask() + if not framework: + raise SystemExit("Aborted.") # Step 5: Neo4j connection - neo4j_type = questionary.select( - "How would you like to connect to Neo4j?", - choices=[ - questionary.Choice("Neo4j Aura (cloud — free tier available)", value="aura"), - questionary.Choice("Local Neo4j via neo4j-local (no Docker required)", value="local"), - questionary.Choice("Local Neo4j via Docker", value="docker"), - questionary.Choice("Existing Neo4j instance", value="existing"), - ], - ).ask() - if not neo4j_type: - raise SystemExit("Aborted.") - - if neo4j_type == "aura": - console.print(Panel( - "[bold]Neo4j Aura — Free Cloud Database[/bold]\n\n" - "1. Sign up at [cyan]https://console.neo4j.io[/cyan]\n" - "2. Create a free AuraDB instance\n" - "3. Download the [bold].env[/bold] file with your credentials\n" - "4. Provide the path to the downloaded file below", - border_style="cyan", - title="Setup", - )) - aura_env_path = questionary.path( - "Path to Neo4j Aura .env file:", + if neo4j_aura_env: + neo4j_type = "aura" + elif neo4j_local: + neo4j_type = "local" + elif neo4j_uri and "aura" in (neo4j_uri or ""): + neo4j_type = "aura" + elif neo4j_uri: + neo4j_type = "existing" + else: + neo4j_type = questionary.select( + "How would you like to connect to Neo4j?", + choices=[ + questionary.Choice("Neo4j Aura (cloud — free tier available)", value="aura"), + questionary.Choice("Local Neo4j via neo4j-local (no Docker required)", value="local"), + questionary.Choice("Local Neo4j via Docker", value="docker"), + questionary.Choice("Existing Neo4j instance", value="existing"), + ], ).ask() - if not aura_env_path: + if not neo4j_type: raise SystemExit("Aborted.") - neo4j_uri, neo4j_username, neo4j_password = _parse_aura_env(aura_env_path) + + if neo4j_type == "aura": + if neo4j_aura_env: + # Use provided Aura env file + neo4j_uri, neo4j_username, neo4j_password = _parse_aura_env(neo4j_aura_env) + else: + console.print(Panel( + "[bold]Neo4j Aura — Free Cloud Database[/bold]\n\n" + "1. Sign up at [cyan]https://console.neo4j.io[/cyan]\n" + "2. Create a free AuraDB instance\n" + "3. Download the [bold].env[/bold] file with your credentials\n" + "4. Provide the path to the downloaded file below", + border_style="cyan", + title="Setup", + )) + aura_env_path = questionary.path( + "Path to Neo4j Aura .env file:", + ).ask() + if not aura_env_path: + raise SystemExit("Aborted.") + neo4j_uri, neo4j_username, neo4j_password = _parse_aura_env(aura_env_path) elif neo4j_type == "local": - neo4j_uri = "neo4j://localhost:7687" - neo4j_username = "neo4j" - neo4j_password = "password" + neo4j_uri = neo4j_uri or "neo4j://localhost:7687" + neo4j_username = neo4j_username or "neo4j" + neo4j_password = neo4j_password or "password" console.print( "[dim]Will use [bold]@johnymontana/neo4j-local[/bold] — " "run [bold]make neo4j-start[/bold] to launch Neo4j (requires Node.js)[/dim]" ) elif neo4j_type == "docker": - neo4j_uri = "neo4j://localhost:7687" - neo4j_username = "neo4j" - neo4j_password = "password" - else: - neo4j_uri = questionary.text( - "Neo4j URI:", - default="neo4j+s://xxxx.databases.neo4j.io", - ).ask() - neo4j_username = questionary.text( - "Neo4j Username:", - default="neo4j", - ).ask() - neo4j_password = questionary.password("Neo4j Password:").ask() + neo4j_uri = neo4j_uri or "neo4j://localhost:7687" + neo4j_username = neo4j_username or "neo4j" + neo4j_password = neo4j_password or "password" + else: # existing + if neo4j_uri and neo4j_username and neo4j_password: + # Use provided values + pass + else: + neo4j_uri = questionary.text( + "Neo4j URI:", + default=neo4j_uri or "neo4j+s://xxxx.databases.neo4j.io", + ).ask() + neo4j_username = questionary.text( + "Neo4j Username:", + default=neo4j_username or "neo4j", + ).ask() + neo4j_password = questionary.password("Neo4j Password:").ask() if not neo4j_uri: raise SystemExit("Aborted.") # Step 6: API Keys (skip Anthropic if already collected for custom domain) if custom_domain_yaml is None: - anthropic_api_key = questionary.password( - "Anthropic API key (for AI agent):", + if anthropic_api_key: + # Use provided API key + pass + else: + anthropic_api_key = questionary.password( + "Anthropic API key (for AI agent):", + default="", + ).ask() + # anthropic_api_key already set from custom domain flow otherwise + + if openai_api_key: + # Use provided OpenAI API key + pass + else: + openai_api_key = questionary.password( + "OpenAI API key (for embeddings, or Enter to skip):", default="", ).ask() - # anthropic_api_key already set from custom domain flow otherwise - openai_api_key = questionary.password( - "OpenAI API key (for embeddings, or Enter to skip):", - default="", - ).ask() + if anthropic_base_url: + # Use provided base URL + pass + else: + anthropic_base_url = questionary.text( + "Anthropic-compatible API base URL (Enter to use default):", + default="", + ).ask() google_api_key = None if framework == "google-adk": @@ -338,6 +419,7 @@ def run_wizard() -> ProjectConfig: neo4j_password=neo4j_password or "password", neo4j_type=neo4j_type, anthropic_api_key=anthropic_api_key or None, + anthropic_base_url=anthropic_base_url or None, openai_api_key=openai_api_key or None, google_api_key=google_api_key or None, generate_data=data_source == "demo", @@ -369,6 +451,7 @@ def _show_summary(config: ProjectConfig) -> None: table.add_row("Connectors", ", ".join(config.saas_connectors)) table.add_row("Neo4j", f"{config.neo4j_type} ({config.neo4j_uri})") table.add_row("Anthropic Key", "***" if config.anthropic_api_key else "(not set)") + table.add_row("Anthropic Base URL", config.anthropic_base_url or "(default)") table.add_row("OpenAI Key", "***" if config.openai_api_key else "(not set)") if config.google_api_key or config.resolved_framework == "google-adk": table.add_row("Google Key", "***" if config.google_api_key else "(not set)") From f5360d840c79da0458b82ca21f77d04882e869df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Dzier=C5=BCanowski?= Date: Sun, 29 Mar 2026 23:49:42 +0200 Subject: [PATCH 2/9] fix: address code review findings for localmodel branch - renderer.py: log exception in agent template fallback (fixes Ruff F841) - langgraph/agent.py.j2: remove unused `import langgraph`, restore missing {% raw %} blocks around run_cypher/get_graph_schema - docker-compose.prod.yml.j2: make ANTHROPIC_BASE_URL conditional - dot_env.j2: fix trailing blank line when anthropic_base_url is set - strands/agent.py.j2: clean up empty raw/endraw block - pydanticai/agent.py.j2: clean up empty raw/endraw block - anthropic_tools/agent.py.j2: clean up empty raw/endraw block --- src/create_context_graph/renderer.py | 2 +- .../templates/backend/agents/anthropic_tools/agent.py.j2 | 2 -- .../templates/backend/agents/langgraph/agent.py.j2 | 4 +++- .../templates/backend/agents/pydanticai/agent.py.j2 | 2 -- .../templates/backend/agents/strands/agent.py.j2 | 2 -- .../templates/base/docker-compose.prod.yml.j2 | 2 ++ src/create_context_graph/templates/base/dot_env.j2 | 3 +-- 7 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/create_context_graph/renderer.py b/src/create_context_graph/renderer.py index c7b4eab..ff1b2cd 100644 --- a/src/create_context_graph/renderer.py +++ b/src/create_context_graph/renderer.py @@ -204,7 +204,7 @@ def _render_backend(self, backend_dir: Path, ctx: dict) -> None: agent_template = f"backend/agents/{fw_key}/agent.py.j2" try: self._render_template(agent_template, backend_dir / "app" / "agent.py", ctx) - except Exception as e: + except Exception: # Fallback: render a minimal agent stub self._render_template( "backend/shared/agent_stub.py.j2", diff --git a/src/create_context_graph/templates/backend/agents/anthropic_tools/agent.py.j2 b/src/create_context_graph/templates/backend/agents/anthropic_tools/agent.py.j2 index 404fdfb..722337d 100644 --- a/src/create_context_graph/templates/backend/agents/anthropic_tools/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/anthropic_tools/agent.py.j2 @@ -25,8 +25,6 @@ if not os.environ.get("ANTHROPIC_API_KEY"): {% if anthropic_base_url %} os.environ["ANTHROPIC_BASE_URL"] = "{{ anthropic_base_url }}" {% endif %} -{% raw %} -{% endraw %} import anthropic diff --git a/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 b/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 index 9cf04f5..1cb8ad5 100644 --- a/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 @@ -5,7 +5,6 @@ from __future__ import annotations import json import uuid -import langgraph from langchain_anthropic import ChatAnthropic from langchain_core.tools import tool from langgraph.prebuilt import create_react_agent @@ -42,6 +41,7 @@ async def {{ tool.name }}({% for param in tool.parameters %}{{ param.name }}: {{ {% endfor %} +{% raw %} @tool async def run_cypher(query: str, parameters: str = "{}") -> str: """Execute a read-only Cypher query against the knowledge graph.""" @@ -56,11 +56,13 @@ async def run_cypher(query: str, parameters: str = "{}") -> str: except Exception as e: return json.dumps({"error": f"Cypher query failed: {e}"}) + @tool async def get_graph_schema() -> str: """Get the knowledge graph schema (node labels and relationship types).""" result = await get_schema() return json.dumps(result, default=str) +{% endraw %} TOOLS = [ {% for tool in agent_tools %} diff --git a/src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2 b/src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2 index ba531eb..d9d362c 100644 --- a/src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2 @@ -26,8 +26,6 @@ if not os.environ.get("ANTHROPIC_API_KEY"): {% if anthropic_base_url %} os.environ["ANTHROPIC_BASE_URL"] = "{{ anthropic_base_url }}" {% endif %} -{% raw %} -{% endraw %} from pydantic_ai import Agent, RunContext diff --git a/src/create_context_graph/templates/backend/agents/strands/agent.py.j2 b/src/create_context_graph/templates/backend/agents/strands/agent.py.j2 index ebfabe0..e10baad 100644 --- a/src/create_context_graph/templates/backend/agents/strands/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/strands/agent.py.j2 @@ -33,8 +33,6 @@ if not os.environ.get("ANTHROPIC_API_KEY"): {% if anthropic_base_url %} os.environ["ANTHROPIC_BASE_URL"] = "{{ anthropic_base_url }}" {% endif %} -{% raw %} -{% endraw %} _main_loop: asyncio.AbstractEventLoop | None = None diff --git a/src/create_context_graph/templates/base/docker-compose.prod.yml.j2 b/src/create_context_graph/templates/base/docker-compose.prod.yml.j2 index c46b7c4..c3893a7 100644 --- a/src/create_context_graph/templates/base/docker-compose.prod.yml.j2 +++ b/src/create_context_graph/templates/base/docker-compose.prod.yml.j2 @@ -33,8 +33,10 @@ services: NEO4J_PASSWORD: ${NEO4J_PASSWORD:-password} {% if 'anthropic' in framework or framework == 'pydanticai' or framework == 'claude-agent-sdk' %} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} +{% if anthropic_base_url %} ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL} {% endif %} +{% endif %} {% if 'openai' in framework or framework == 'langgraph' %} OPENAI_API_KEY: ${OPENAI_API_KEY} {% endif %} diff --git a/src/create_context_graph/templates/base/dot_env.j2 b/src/create_context_graph/templates/base/dot_env.j2 index e910f2c..2b94b02 100644 --- a/src/create_context_graph/templates/base/dot_env.j2 +++ b/src/create_context_graph/templates/base/dot_env.j2 @@ -9,8 +9,7 @@ NEO4J_PASSWORD={{ neo4j_password }} # LLM API Keys ANTHROPIC_API_KEY={{ anthropic_api_key }} {% if anthropic_base_url %}ANTHROPIC_BASE_URL={{ anthropic_base_url }} -{% endif %} -OPENAI_API_KEY={{ openai_api_key }} +{% endif %}OPENAI_API_KEY={{ openai_api_key }} GOOGLE_API_KEY={{ google_api_key }} # Application From 1bfde1ee1782eea980049641d642d69e7533e348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Dzier=C5=BCanowski?= Date: Mon, 30 Mar 2026 00:22:09 +0200 Subject: [PATCH 3/9] fix: propagate custom domain base_url through wizard When a user provides an Anthropic base URL during custom domain generation, that value was not carried forward. The wizard would prompt again and the generated project could end up with a different (or empty) endpoint. Assign anthropic_base_url = custom_base_url after the custom domain flow so the later prompt is skipped and the project config stays consistent. --- src/create_context_graph/wizard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/create_context_graph/wizard.py b/src/create_context_graph/wizard.py index 8aba0d6..bdc4b9a 100644 --- a/src/create_context_graph/wizard.py +++ b/src/create_context_graph/wizard.py @@ -278,8 +278,9 @@ def run_wizard( if save: save_custom_domain(custom_ontology, custom_domain_yaml) - # Store the API key for later use + # Store the API key and base URL for later use anthropic_api_key = custom_api_key + anthropic_base_url = custom_base_url # Step 4: Agent framework if framework: From b37616006ed9b43062da2ed0b53f945eed33650c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Dzier=C5=BCanowski?= Date: Mon, 30 Mar 2026 00:24:29 +0200 Subject: [PATCH 4/9] fix: guard against empty response.content in _llm_generate If the Anthropic SDK returns an empty content list, indexing response.content[0] would raise IndexError. Add an early return for this edge case. --- src/create_context_graph/generator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/create_context_graph/generator.py b/src/create_context_graph/generator.py index e352919..a0dea6a 100644 --- a/src/create_context_graph/generator.py +++ b/src/create_context_graph/generator.py @@ -73,6 +73,8 @@ def _llm_generate(client, provider: str, prompt: str, system: str = "") -> str: system=system, messages=[{"role": "user", "content": prompt}], ) + if not response.content: + return "" # Handle both text content and thinking blocks content = response.content[0] if hasattr(content, 'text'): From aba3ee915af8045a21284a8a692e98f1e1b7e589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Dzier=C5=BCanowski?= Date: Mon, 30 Mar 2026 00:26:49 +0200 Subject: [PATCH 5/9] fix: use runtime settings for anthropic_base_url in langgraph template The base URL was baked as a literal string at scaffold time, preventing users from changing it via .env after generation. Read from settings.anthropic_base_url at runtime instead, matching how api_key is already handled. --- .../templates/backend/agents/langgraph/agent.py.j2 | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 b/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 index 1cb8ad5..d2c77c9 100644 --- a/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 @@ -72,13 +72,12 @@ TOOLS = [ get_graph_schema, ] -model = ChatAnthropic( - model="claude-sonnet-4-20250514", - api_key=settings.anthropic_api_key, -{% if anthropic_base_url %} - base_url="{{ anthropic_base_url }}", -{% endif %} -) +{% raw %} +_anthropic_kwargs = {"model": "claude-sonnet-4-20250514", "api_key": settings.anthropic_api_key} +if settings.anthropic_base_url: + _anthropic_kwargs["base_url"] = settings.anthropic_base_url +model = ChatAnthropic(**_anthropic_kwargs) +{% endraw %} graph = create_react_agent(model, TOOLS, prompt=SYSTEM_PROMPT) From 26ed9182367e76487749a981049e596051ec52ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Dzier=C5=BCanowski?= Date: Mon, 30 Mar 2026 00:27:52 +0200 Subject: [PATCH 6/9] fix: use runtime settings for anthropic_base_url in claude_agent_sdk template Same as the langgraph fix: read base_url from settings at runtime instead of baking a literal at scaffold time. --- .../templates/backend/agents/claude_agent_sdk/agent.py.j2 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/create_context_graph/templates/backend/agents/claude_agent_sdk/agent.py.j2 b/src/create_context_graph/templates/backend/agents/claude_agent_sdk/agent.py.j2 index 2125eff..681c76a 100644 --- a/src/create_context_graph/templates/backend/agents/claude_agent_sdk/agent.py.j2 +++ b/src/create_context_graph/templates/backend/agents/claude_agent_sdk/agent.py.j2 @@ -21,10 +21,11 @@ IMPORTANT: You MUST use the available tools to query the knowledge graph before CRITICAL: Call tools DIRECTLY without any introductory text. Do NOT say "I'll search for..." or "Let me look up..." before calling a tool — just call the tool immediately. Only generate text AFTER you have received the tool results and are ready to provide your final answer.""" +{% raw %} client_kwargs = {"api_key": settings.anthropic_api_key} -{% if anthropic_base_url %} -client_kwargs["base_url"] = "{{ anthropic_base_url }}" -{% endif %} +if settings.anthropic_base_url: + client_kwargs["base_url"] = settings.anthropic_base_url +{% endraw %} client = anthropic.AsyncAnthropic(**client_kwargs) # --------------------------------------------------------------------------- From c955fe57c737547a088f4ed00db749c1da3efbc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Dzier=C5=BCanowski?= Date: Mon, 30 Mar 2026 00:28:33 +0200 Subject: [PATCH 7/9] docs: align Neo4j URI in README with generated .env.example The README said bolt:// while .env.example uses neo4j://. Note both forms are accepted to avoid confusion. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e73767a..8436b50 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ cat /tmp/neo4j-local.log # View generated password ``` **Connection Details:** -- URI: `bolt://localhost:7687` +- URI: `neo4j://localhost:7687` (the `bolt://` form is also supported) - Username: `neo4j` - Database: `neo4j` - Password: Generated automatically (check logs) From 2b63b0fabcd6fbdf4ffeb0afa84f0f661b9e7cca Mon Sep 17 00:00:00 2001 From: tomd Date: Fri, 3 Apr 2026 11:21:39 +0200 Subject: [PATCH 8/9] removing bug --- src/create_context_graph/wizard.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/create_context_graph/wizard.py b/src/create_context_graph/wizard.py index bdc4b9a..4aa67fb 100644 --- a/src/create_context_graph/wizard.py +++ b/src/create_context_graph/wizard.py @@ -397,6 +397,9 @@ def run_wizard( else: anthropic_base_url = questionary.text( "Anthropic-compatible API base URL (Enter to use default):", + default="", + ).ask() + if framework == "openai-agents": openai_api_key = questionary.password( "OpenAI API key (required for OpenAI Agents SDK):", From 7a51cd2393515509a94bbb01e47001842f82caa1 Mon Sep 17 00:00:00 2001 From: William Lyon Date: Tue, 28 Apr 2026 11:02:59 -0600 Subject: [PATCH 9/9] Update src/create_context_graph/renderer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/create_context_graph/renderer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/create_context_graph/renderer.py b/src/create_context_graph/renderer.py index ff1b2cd..f6af602 100644 --- a/src/create_context_graph/renderer.py +++ b/src/create_context_graph/renderer.py @@ -302,7 +302,6 @@ def _render_cypher(self, cypher_dir: Path, ctx: dict) -> None: def _render_data(self, data_dir: Path, ctx: dict) -> None: """Copy ontology and create data directory structure.""" from create_context_graph.ontology import _get_domains_path - data_dir.mkdir(parents=True, exist_ok=True) (data_dir / "documents").mkdir(exist_ok=True)