Skip to content

--from-database: Scaffold projects from existing Neo4j db - #31

Open
akkonrad wants to merge 8 commits into
neo4j-labs:mainfrom
akkonrad:use-existing-db
Open

--from-database: Scaffold projects from existing Neo4j db#31
akkonrad wants to merge 8 commits into
neo4j-labs:mainfrom
akkonrad:use-existing-db

Conversation

@akkonrad

@akkonrad akkonrad commented May 8, 2026

Copy link
Copy Markdown

Summary

New --from-database CLI flag that discovers the domain ontology from a live Neo4j database — no YAML authoring required. Introspects labels, relationships, properties, constraints, indexes, and node counts, then auto-generates a complete domain ontology with POLE-type classification, system prompt, and demo scenarios.

Also adds Cypher safety guards and Plotly chart rendering to all 8 agent frameworks.

What's included

Core:
New discovery.py module with schema introspection (8 Neo4j queries), POLE-type classification heuristics, Neo4j-to-ontology type mapping, constraint-based unique property detection, and LLM-powered system prompt refinement. New from_database field on ProjectConfig. CLI wiring in cli.py with validation, discovery flow, conditional rendering, and non-interactive mode support. Renderer updates to skip
schema.cypher and conditionally render generate_data.py and Makefile targets.

Cypher safety guards (all projects):
New cypher_guard.py.j2 template with validate_read_only() that blocks write operations (CREATE, MERGE, DELETE, DROP, etc.) and enforce_row_limit() that caps query results at 500 rows. Integrated into run_cypher across all 8 agent frameworks.

Plotly chart rendering (all projects):
New chart_builder.py.j2 template that builds Plotly JSON specs for bar, line, scatter, pie, horizontal bar, and table charts with auto-field detection and Neo4j node flattening. New create_chart agent tool in all 8 frameworks. New ChartPanel.tsx.j2 frontend component with dynamic Plotly import (SSR-safe). New chart_data SSE event with emit_chart_data on
CypherResultCollector and handling in ChatInterface.tsx.j2.

From-database-only agent tools:
get_schema for runtime schema introspection with property details. sample_data for safe label-validated data sampling (capped at 10 rows). Both conditionally rendered only when from_database=True.

Conditional rendering:
schema.cypher skipped when from_database=True. generate_data.py skips apply_schema() step. Makefile omits schema: target. Success message shows make start instead of make seed.

Interactive system prompt refinement:
In TTY mode, users can edit the auto-generated prompt or provide a rough description that gets refined via LLM with an iterative Accept/Refine/Edit loop.

Bugfix:
config.ts.j2 changed DEFAULT_CYPHER from template literal to double-quoted string to fix backtick collision with Neo4j label quoting.

Tests:
~145 new tests across 8 test files (3 new, 5 extended) covering discovery, cypher guard, chart builder, renderer, CLI, generated project validation, security, and frontend SSE contract. Total: 1205 passing.

Usage

create-context-graph my-app --from-database \
--neo4j-uri neo4j+s://xxxxx.databases.neo4j.io \ 
--neo4j-username neo4j \
--neo4j-password secret \
--framework pydanticai
 
Test plan
 
- All 1205 unit tests pass (pytest tests/ -v)
- Tested against production Neo4j Aura database with --from-database
- Generated project starts and connects to database successfully 
- Cypher guard blocks write queries, enforces row limits
- Chart rendering works end-to-end (agent → SSE → frontend)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a --from-database scaffolding mode that introspects a live Neo4j database to auto-build a domain ontology (no YAML), and expands generated apps with Cypher safety guards plus Plotly chart rendering (backend → SSE → frontend) across all supported agent frameworks.

Changes:

  • Introduces Neo4j discovery + ontology construction (discovery.py) and wires --from-database through CLI/config/renderer with conditional template rendering.
  • Adds generated Cypher safety utilities (cypher_guard.py) and integrates them into run_cypher across agent templates.
  • Adds chart building + rendering support (chart_builder.py, SSE chart_data, ChartPanel) and integrates a create_chart tool into all agent templates, with expanded tests.

Reviewed changes

Copilot reviewed 30 out of 30 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
tests/test_security.py Adds assertions that generated run_cypher imports/uses Cypher guard helpers.
tests/test_renderer.py Adds rendering tests for --from-database behavior and new shared modules/components.
tests/test_generated_project.py Validates generated projects include/omit tools appropriately and new modules are valid Python.
tests/test_frontend_logic.py Extends SSE contract validation to include chart_data events.
tests/test_discovery.py New unit tests for discovery, ontology building, and error handling.
tests/test_cypher_guard.py New unit tests for generated Cypher guard logic.
tests/test_config.py Adds tests for new ProjectConfig.from_database flag default/behavior.
tests/test_cli.py Adds CLI wiring tests for --from-database validation and dry-run behavior.
tests/test_chart_builder.py New unit tests for generated Plotly spec builder.
src/create_context_graph/templates/frontend/package.json.j2 Adds Plotly dependencies for chart rendering.
src/create_context_graph/templates/frontend/lib/config.ts.j2 Adjusts how DEFAULT_CYPHER is emitted (string literal).
src/create_context_graph/templates/frontend/components/ChatInterface.tsx.j2 Adds chart_data SSE handling and renders ChartPanel.
src/create_context_graph/templates/frontend/components/ChartPanel.tsx.j2 New Plotly chart panel component (SSR-safe dynamic import).
src/create_context_graph/templates/base/Makefile.j2 Conditionally omits schema target in --from-database mode.
src/create_context_graph/templates/backend/shared/generate_data.py.j2 Skips schema application in --from-database mode.
src/create_context_graph/templates/backend/shared/cypher_guard.py.j2 New generated read-only validation + row limit enforcement.
src/create_context_graph/templates/backend/shared/context_graph_client.py.j2 Adds emit_chart_data SSE event emission helper.
src/create_context_graph/templates/backend/shared/chart_builder.py.j2 New generated Plotly spec builder used by agent tool(s).
src/create_context_graph/templates/backend/agents/strands/agent.py.j2 Integrates Cypher guard + chart tool + from-db-only tools.
src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2 Integrates Cypher guard + chart tool + from-db-only tools.
src/create_context_graph/templates/backend/agents/openai_agents/agent.py.j2 Integrates Cypher guard + chart tool + from-db-only tools.
src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2 Integrates Cypher guard + chart tool + from-db-only tools.
src/create_context_graph/templates/backend/agents/google_adk/agent.py.j2 Integrates Cypher guard + chart tool + from-db-only tools.
src/create_context_graph/templates/backend/agents/crewai/agent.py.j2 Integrates Cypher guard + chart tool + from-db-only tools.
src/create_context_graph/templates/backend/agents/claude_agent_sdk/agent.py.j2 Renames tool to run_cypher, adds chart tool, and from-db-only tools.
src/create_context_graph/templates/backend/agents/anthropic_tools/agent.py.j2 Renames tool to run_cypher, adds chart tool, and from-db-only tools.
src/create_context_graph/renderer.py Adds from_database context + conditionally skips schema.cypher rendering; renders new shared modules/components.
src/create_context_graph/discovery.py New discovery + ontology-building implementation and prompt refinement helper.
src/create_context_graph/config.py Adds ProjectConfig.from_database flag.
src/create_context_graph/cli.py Adds --from-database flow, schema discovery, and optional interactive system-prompt refinement.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

export const NODE_SIZES: Record<string, number> = {{ visualization.nodeSizes | tojson }};

export const DEFAULT_CYPHER = `{{ visualization.defaultCypher }}`;
export const DEFAULT_CYPHER = "{{ visualization.defaultCypher }}";
Comment on lines +168 to +173
trace = {
"type": chart_type,
"x": x_values,
"y": y_values,
}
if chart_type == "scatter":
return json.dumps({"error": f"Unknown label: {label}"})

bounded_limit = max(1, min(limit, 10))
query = f"MATCH (n:`{label}`) RETURN n LIMIT $limit"
return json.dumps({"error": f"Unknown label: {label}"})

bounded_limit = max(1, min(limit, 10))
query = f"MATCH (n:`{label}`) RETURN n LIMIT $limit"
return json.dumps({"error": f"Unknown label: {label}"})

bounded_limit = max(1, min(limit, 10))
query = f"MATCH (n:`{label}`) RETURN n LIMIT $limit"
return json.dumps({"error": f"Unknown label: {label}"})

bounded_limit = max(1, min(limit, 10))
query = f"MATCH (n:`{label}`) RETURN n LIMIT $limit"
return json.dumps({"error": f"Unknown label: {label}"})

bounded_limit = max(1, min(limit, 10))
query = f"MATCH (n:`{label}`) RETURN n LIMIT $limit"
return json.dumps({"error": f"Unknown label: {label}"})

bounded_limit = max(1, min(limit, 10))
query = f"MATCH (n:`{label}`) RETURN n LIMIT $limit"
return json.dumps({"error": f"Unknown label: {label}"})

bounded_limit = max(1, min(int(tool_input.get("limit", 5)), 10))
query = f"MATCH (n:`{label}`) RETURN n LIMIT $limit"
return json.dumps({"error": f"Unknown label: {label}"})

bounded_limit = max(1, min(limit, 10))
query = f"MATCH (n:`{label}`) RETURN n LIMIT $limit"

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 11 comments.

Comments suppressed due to low confidence (7)

src/create_context_graph/templates/backend/shared/cypher_guard.py.j2:58

  • _NUMERIC_LIMIT_RE.sub(cap_limit, query) substitutes every LIMIT <n> in the query, including LIMITs inside subqueries / CALL { ... } blocks. If a user writes a subquery with a small inner LIMIT 5 for pagination and a larger outer LIMIT 1000, only the outer LIMIT should be capped to 500; the current code touches all matches, which can change query semantics. Consider only capping the trailing/outermost LIMIT, or document this behaviour explicitly.
def enforce_row_limit(query: str, default_limit: int = DEFAULT_ROW_LIMIT) -> str:
    """Ensure a Cypher query has a bounded LIMIT clause."""
    if _LIMIT_RE.search(query):

        def cap_limit(match: re.Match[str]) -> str:
            limit_value = int(match.group(2))
            if limit_value > MAX_ROW_LIMIT:
                return f"{match.group(1)}{MAX_ROW_LIMIT}"
            return match.group(0)

        return _NUMERIC_LIMIT_RE.sub(cap_limit, query)

src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2:160

  • In --from-database mode the agent ends up registering two near-identical tools: the pre-existing get_graph_schema (returning labels + relationship types) and the new get_schema (returning the same plus property details). Their names are confusingly similar and their docstrings both talk about "the schema", so the LLM is likely to call the wrong one or call both. Consider either renaming get_schema to something more descriptive (e.g. get_property_schema / describe_properties) or having the from-database tool replace get_graph_schema rather than coexist with it.
@agent.tool
async def get_schema(ctx: RunContext[AgentDeps]) -> str:
    """Get the database schema with discovered node property information."""
    schema = await fetch_schema()

src/create_context_graph/cli.py:377

  • The earlier branch if not project_name and (domain or custom_domain) and framework: already assigns project_name for the from-database case (because domain has been set to "discovered-database" at line 344). The follow-up branch if not project_name and from_database and framework and domain: is therefore dead code and will never execute. Either remove it, or move it before the previous branch if a different naming scheme was intended for from-database projects (e.g. derived from the Neo4j URI/database).
    if not project_name and from_database and framework and domain:
        project_name = f"{domain}-{framework}-app"

src/create_context_graph/discovery.py:285

  • refine_system_prompt does response.content[0].text without checking that response.content is non-empty or that the first block is a text block (it can be a tool-use or thinking block depending on the model). If the Anthropic API returns anything other than a single text block first, this raises IndexError / AttributeError and aborts the interactive flow. Consider iterating over response.content and picking the first text block, with a clear error if none is present.
    return response.content[0].text.strip()

src/create_context_graph/templates/backend/shared/cypher_guard.py.j2:61

  • When a query already contains a LIMIT clause but it is not a plain numeric literal (e.g. LIMIT $limit, LIMIT toInteger($n), LIMIT 100 + 1), _LIMIT_RE.search returns True so no default limit is appended, and _NUMERIC_LIMIT_RE.sub doesn't match so nothing is capped. The result is a query with a user-controlled or arbitrary expression as its limit, completely bypassing MAX_ROW_LIMIT. Either also wrap parameterised/expression LIMITs (e.g. using apoc.cypher.runFirstColumn or WITH ... LIMIT $limit_bounded), or, more simply, reject queries whose existing LIMIT cannot be statically bounded.
def enforce_row_limit(query: str, default_limit: int = DEFAULT_ROW_LIMIT) -> str:
    """Ensure a Cypher query has a bounded LIMIT clause."""
    if _LIMIT_RE.search(query):

        def cap_limit(match: re.Match[str]) -> str:
            limit_value = int(match.group(2))
            if limit_value > MAX_ROW_LIMIT:
                return f"{match.group(1)}{MAX_ROW_LIMIT}"
            return match.group(0)

        return _NUMERIC_LIMIT_RE.sub(cap_limit, query)

    stripped_query = query.rstrip().rstrip(";").rstrip()
    return f"{stripped_query}\nLIMIT {default_limit}"

src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2:144

  • Eight agent templates each contain near-identical copies of create_chart, run_cypher (now with the guard), get_schema, and sample_data. Any future change to chart handling, the guard contract, or the from-database tools has to be applied (and tested) in 8 places, which is exactly the situation this PR's "+~145 tests" budget tries to compensate for. Consider extracting the bodies into shared helper modules (e.g. app.tools_common) that each framework wrapper simply re-exports / decorates, so the per-framework template only contains the framework-specific decorator and signature.
@agent.tool
async def create_chart(
    ctx: RunContext[AgentDeps],
    chart_type: str,
    title: str,
    data: str,
    x_field: str = "",
    y_field: str = "",
    labels_field: str = "",
    values_field: str = "",
) -> str:
    """Create a Plotly chart from query result data."""
    try:
        rows = json.loads(data)
    except json.JSONDecodeError as e:
        return json.dumps({"error": f"Invalid JSON chart data: {e}"})

    try:
        spec = build_plotly_spec(
            chart_type,
            title,
            rows,
            x_field=x_field,
            y_field=y_field,
            labels_field=labels_field,
            values_field=values_field,
        )
    except ChartBuildError as e:
        return json.dumps({"error": str(e)})

    collector = get_collector()
    collector.emit_chart_data(spec)
    return json.dumps({"status": "success", "chart": spec}, default=str)

src/create_context_graph/templates/backend/shared/chart_builder.py.j2:149

  • For the pie chart path, _auto_detect_fields(rows, labels_field, values_field) requires the labels field to be a string-typed column and the values field to be numeric. If the data is [{"id": 1, "count": 10}, ...] (no string field at all — common when a Cypher result groups by id) the function raises ChartBuildError("Could not determine x and y fields for chart") with a message that mentions x/y, not labels/values, which will confuse users debugging a pie chart. Consider a dedicated detection / error message for pie charts.
    if chart_type == "pie":
        labels_field = labels_field or x_field
        values_field = values_field or y_field
        labels_field, values_field = _auto_detect_fields(
            rows, labels_field, values_field
        )
        return {
            "data": [
                {
                    "type": "pie",
                    "labels": _extract_values(rows, labels_field),
                    "values": _extract_values(rows, values_field),
                }
            ],
            "layout": {"title": title},
        }

"""
client = anthropic.Anthropic(api_key=api_key)
response = client.messages.create(
model="claude-sonnet-4-6",
Comment thread src/create_context_graph/discovery.py Outdated
Comment on lines +40 to +41
"Time": "datetime",
"LocalTime": "datetime",
Comment on lines +34 to +45
def validate_read_only(query: str) -> None:
"""Validate that a Cypher query does not contain write operations."""
query_upper = query.upper()

for pattern in _DDL_PATTERNS:
if pattern in query_upper:
raise CypherGuardError(f"Write operation is not allowed: {pattern}")

for raw_token in re.split(r"[\s()]+", query_upper):
token = raw_token.strip(";,.(){}")
if token in _WRITE_KEYWORDS:
raise CypherGuardError(f"Write operation is not allowed: {token}")
Comment on lines +8 to +9
DEFAULT_ROW_LIMIT = 100
MAX_ROW_LIMIT = 500
Comment on lines 100 to 101
return json.dumps({"error": "Invalid JSON parameters"})
params.setdefault("domain", settings.domain_id)
Comment on lines +38 to +64
def _auto_detect_fields(
data: list[Any], prefer_x: str = "", prefer_y: str = ""
) -> tuple[str, str]:
"""Detect x/y fields from the first row when explicit fields are missing."""
x_field = prefer_x
y_field = prefer_y

first_row = data[0]
if not isinstance(first_row, dict):
raise ChartBuildError("Chart data rows must be dictionaries")

if not x_field:
for field, value in first_row.items():
if isinstance(value, str):
x_field = field
break

if not y_field:
for field, value in first_row.items():
if isinstance(value, Number) and not isinstance(value, bool):
y_field = field
break

if not x_field or not y_field:
raise ChartBuildError("Could not determine x and y fields for chart")

return x_field, y_field
Comment on lines +33 to +36
try:
from app.constants import ENTITY_LABELS
except ImportError:
ENTITY_LABELS = [{% for et in entity_types %}"{{ et.label }}"{% if not loop.last %}, {% endif %}{% endfor %}]
Comment on lines 57 to +98
@@ -57,7 +66,36 @@ TOOLS = [
},
},
{
"name": "get_schema",
"name": "create_chart",
"description": "Create a Plotly chart from query result data",
"input_schema": {
"type": "object",
"properties": {
"chart_type": {
"type": "string",
"description": "Chart type: bar, line, scatter, pie, hbar, or table",
},
"title": {"type": "string", "description": "Chart title"},
"data": {
"type": "string",
"description": "JSON array of records to visualize",
},
"x_field": {"type": "string", "description": "Field for x-axis values"},
"y_field": {"type": "string", "description": "Field for y-axis values"},
"labels_field": {
"type": "string",
"description": "Field for pie chart labels",
},
"values_field": {
"type": "string",
"description": "Field for pie chart values",
},
},
"required": ["chart_type", "title", "data"],
},
},
{
"name": "get_graph_schema",
Comment on lines 408 to +409
custom_domain_yaml=custom_domain_yaml,
saas_connectors=list(connector),
saas_connectors=[] if from_database else list(connector),
Comment on lines +335 to +344
discovered_ontology = build_ontology_from_discovery(
discovered_schema,
"discovered-database",
)
_refine_discovered_system_prompt(
discovered_ontology,
discovered_schema,
anthropic_api_key,
)
domain = discovered_ontology.domain.id
@johnymontana

Copy link
Copy Markdown
Collaborator

@copilot apply changes based on the comments in this thread

Konrad added 3 commits May 18, 2026 21:35
- Introduced _derive_domain_id function to derive domain ID from Neo4j URI.
- Updated error handling for database connection parameters in main function.
- Adjusted agent templates to conditionally set domain parameters based on database source.
- Refined chart builder logic to better detect y-axis fields.
- Added from_database setting to application configuration.
- Included ENTITY_LABELS definition in constants for better entity management.
- Improved Cypher query validation to prevent write operations.
# Conflicts:
#	src/create_context_graph/cli.py
#	src/create_context_graph/discovery.py
#	src/create_context_graph/templates/backend/agents/anthropic_tools/agent.py.j2
#	src/create_context_graph/templates/backend/agents/claude_agent_sdk/agent.py.j2
#	src/create_context_graph/templates/backend/agents/crewai/agent.py.j2
#	src/create_context_graph/templates/backend/agents/google_adk/agent.py.j2
#	src/create_context_graph/templates/backend/agents/langgraph/agent.py.j2
#	src/create_context_graph/templates/backend/agents/openai_agents/agent.py.j2
#	src/create_context_graph/templates/backend/agents/pydanticai/agent.py.j2
#	src/create_context_graph/templates/backend/agents/strands/agent.py.j2
#	src/create_context_graph/templates/backend/shared/chart_builder.py.j2
#	src/create_context_graph/templates/backend/shared/cypher_guard.py.j2
#	src/create_context_graph/templates/frontend/lib/config.ts.j2
#	tests/test_chart_builder.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 75 out of 80 changed files in this pull request and generated 7 comments.

Comment on lines +9 to +12
neo4j_uri: str = "neo4j+s://65d988d7.databases.neo4j.io"
neo4j_username: str = "neo4j"
neo4j_password: str = "3ivJaobtTEg6UpPdQYM4rs6J00cEKWDvzO-E2ZQMwWs"
anthropic_api_key: str = ""
Comment on lines +167 to +175
else:
trace = {
"type": chart_type,
"x": x_values,
"y": y_values,
}
if chart_type == "scatter":
trace["mode"] = "markers"
layout = {
Comment on lines +34 to +46
def validate_read_only(query: str) -> None:
"""Validate that a Cypher query does not contain write operations."""
query_upper = query.upper()

for pattern in _DDL_PATTERNS:
if pattern in query_upper:
raise CypherGuardError(f"Write operation is not allowed: {pattern}")

for raw_token in re.split(r"[\s()]+", query_upper):
token = raw_token.strip(";,.(){}")
if token in _WRITE_KEYWORDS:
raise CypherGuardError(f"Write operation is not allowed: {token}")

Comment on lines +213 to +221
@router.post("/cypher")
async def cypher(request: CypherRequest):
"""Execute a Cypher query."""
_require_neo4j()
try:
params = dict(request.parameters or {})
params.setdefault("domain", settings.domain_id)
results = await execute_cypher(request.query, params)
return {"results": results}
Comment on lines +13 to +22
# Ensure ANTHROPIC_API_KEY env var is set before PydanticAI creates the provider.
# pydantic-settings may load an empty value from the shell env, overriding .env.
if not os.environ.get("ANTHROPIC_API_KEY"):
if settings.anthropic_api_key:
os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key
else:
from dotenv import dotenv_values
_key = dotenv_values("../.env").get("ANTHROPIC_API_KEY", "")
if _key:
os.environ["ANTHROPIC_API_KEY"] = _key
Comment on lines +5 to +10
// Full entity projection (all node labels and relationship types)
CALL gds.graph.project(
'discovered_database_full',
['Appearance', 'Attack', 'Block', 'Chat', 'ChatSession', 'Dig', 'Event', 'Freeball', 'GameInterruption', 'League', 'Match', 'Player', 'Rally', 'Reception', 'RotationState', 'Serve', 'Set', 'SetAction', 'Staff', 'Stats', 'Substitution', 'Tactic', 'Team', 'XAFactor'],
[]
);
r"\bCALL\s+(apoc\.(?:refactor|create|nodes|merge)\.|gds\.\w+\.(?:write|mutate))",
re.IGNORECASE,
)
_STRING_LITERAL_RE = re.compile(r"""'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*\"""")
# Conflicts:
#	src/create_context_graph/cli.py
#	src/create_context_graph/templates/frontend/components/ChatInterface.tsx.j2
#	tests/test_cli.py
#	tests/test_generated_project.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants