--from-database: Scaffold projects from existing Neo4j db - #31
Conversation
There was a problem hiding this comment.
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-databasethrough CLI/config/renderer with conditional template rendering. - Adds generated Cypher safety utilities (
cypher_guard.py) and integrates them intorun_cypheracross agent templates. - Adds chart building + rendering support (
chart_builder.py, SSEchart_data,ChartPanel) and integrates acreate_charttool 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 }}"; |
| 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" |
There was a problem hiding this comment.
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 everyLIMIT <n>in the query, including LIMITs inside subqueries /CALL { ... }blocks. If a user writes a subquery with a small innerLIMIT 5for pagination and a larger outerLIMIT 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-databasemode the agent ends up registering two near-identical tools: the pre-existingget_graph_schema(returning labels + relationship types) and the newget_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 renamingget_schemato something more descriptive (e.g.get_property_schema/describe_properties) or having the from-database tool replaceget_graph_schemarather 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 assignsproject_namefor the from-database case (becausedomainhas been set to"discovered-database"at line 344). The follow-up branchif 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_promptdoesresponse.content[0].textwithout checking thatresponse.contentis 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 raisesIndexError/AttributeErrorand aborts the interactive flow. Consider iterating overresponse.contentand picking the firsttextblock, 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
LIMITclause but it is not a plain numeric literal (e.g.LIMIT $limit,LIMIT toInteger($n),LIMIT 100 + 1),_LIMIT_RE.searchreturns True so no default limit is appended, and_NUMERIC_LIMIT_RE.subdoesn't match so nothing is capped. The result is a query with a user-controlled or arbitrary expression as its limit, completely bypassingMAX_ROW_LIMIT. Either also wrap parameterised/expression LIMITs (e.g. usingapoc.cypher.runFirstColumnorWITH ... 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, andsample_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
piechart 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 raisesChartBuildError("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", |
| "Time": "datetime", | ||
| "LocalTime": "datetime", |
| 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}") |
| DEFAULT_ROW_LIMIT = 100 | ||
| MAX_ROW_LIMIT = 500 |
| return json.dumps({"error": "Invalid JSON parameters"}) | ||
| params.setdefault("domain", settings.domain_id) |
| 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 |
| try: | ||
| from app.constants import ENTITY_LABELS | ||
| except ImportError: | ||
| ENTITY_LABELS = [{% for et in entity_types %}"{{ et.label }}"{% if not loop.last %}, {% endif %}{% endfor %}] |
| @@ -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", | |||
| custom_domain_yaml=custom_domain_yaml, | ||
| saas_connectors=list(connector), | ||
| saas_connectors=[] if from_database else list(connector), |
| 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 |
|
@copilot apply changes based on the comments in this thread |
- 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
| neo4j_uri: str = "neo4j+s://65d988d7.databases.neo4j.io" | ||
| neo4j_username: str = "neo4j" | ||
| neo4j_password: str = "3ivJaobtTEg6UpPdQYM4rs6J00cEKWDvzO-E2ZQMwWs" | ||
| anthropic_api_key: str = "" |
| else: | ||
| trace = { | ||
| "type": chart_type, | ||
| "x": x_values, | ||
| "y": y_values, | ||
| } | ||
| if chart_type == "scatter": | ||
| trace["mode"] = "markers" | ||
| layout = { |
| 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}") | ||
|
|
| @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} |
| # 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 |
| // 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
Summary
New
--from-databaseCLI 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.pymodule 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. Newfrom_databasefield onProjectConfig. CLI wiring incli.pywith validation, discovery flow, conditional rendering, and non-interactive mode support. Renderer updates to skipschema.cypherand conditionally rendergenerate_data.pyandMakefiletargets.Cypher safety guards (all projects):
New
cypher_guard.py.j2template withvalidate_read_only()that blocks write operations (CREATE, MERGE, DELETE, DROP, etc.) andenforce_row_limit()that caps query results at 500 rows. Integrated intorun_cypheracross all 8 agent frameworks.Plotly chart rendering (all projects):
New
chart_builder.py.j2template that builds Plotly JSON specs for bar, line, scatter, pie, horizontal bar, and table charts with auto-field detection and Neo4j node flattening. Newcreate_chartagent tool in all 8 frameworks. NewChartPanel.tsx.j2frontend component with dynamic Plotly import (SSR-safe). Newchart_dataSSE event withemit_chart_dataonCypherResultCollectorand handling inChatInterface.tsx.j2.From-database-only agent tools:
get_schemafor runtime schema introspection with property details.sample_datafor safe label-validated data sampling (capped at 10 rows). Both conditionally rendered only whenfrom_database=True.Conditional rendering:
schema.cypherskipped whenfrom_database=True.generate_data.pyskipsapply_schema()step.Makefileomitsschema:target. Success message showsmake startinstead ofmake 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.j2changedDEFAULT_CYPHERfrom 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)