Skip to content

Commit f44d9f9

Browse files
Enabling AWS (Redshift + EMR) in chuck-data (#70)
1 parent 800daf6 commit f44d9f9

111 files changed

Lines changed: 21344 additions & 970 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

chuck_data/agent/manager.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
get_active_schema,
99
get_warehouse_id,
1010
get_workspace_url,
11+
get_data_provider,
1112
)
1213

1314
from .prompts import (
14-
DEFAULT_SYSTEM_MESSAGE,
15+
get_default_system_message,
1516
PII_AGENT_SYSTEM_MESSAGE,
1617
BULK_PII_AGENT_SYSTEM_MESSAGE,
1718
STITCH_AGENT_SYSTEM_MESSAGE,
@@ -25,9 +26,12 @@ def __init__(self, client, model=None, tool_output_callback=None, llm_client=Non
2526
self.llm_client = llm_client or LLMProviderFactory.create()
2627
self.model = model
2728
self.tool_output_callback = tool_output_callback
28-
self.conversation_history = [
29-
{"role": "system", "content": DEFAULT_SYSTEM_MESSAGE}
30-
]
29+
30+
# Get provider-aware system message
31+
provider = get_data_provider()
32+
system_message = get_default_system_message(provider or "databricks")
33+
34+
self.conversation_history = [{"role": "system", "content": system_message}]
3135

3236
def add_user_message(self, content):
3337
self.conversation_history.append({"role": "user", "content": content})
@@ -162,6 +166,26 @@ def process_with_tools(self, tools, max_iterations: int = 20):
162166
# Prepare a temporary history copy for this specific LLM call
163167
current_history = deepcopy(self.conversation_history)
164168

169+
# Sanitize messages to ensure content is never empty string for non-tool roles
170+
# Some LLM APIs reject messages with empty content, but allow None for tool_calls
171+
for msg in current_history:
172+
if msg["role"] in ["user", "system"]:
173+
# user and system messages MUST have non-empty content
174+
if not msg.get("content"):
175+
msg["content"] = " " # Use single space as minimum content
176+
logging.warning(
177+
f"Empty content in {msg['role']} message, replacing with space"
178+
)
179+
elif msg["role"] == "assistant":
180+
# assistant messages can have None content if they have tool_calls
181+
if "tool_calls" not in msg or not msg["tool_calls"]:
182+
# No tool calls, so content must be non-empty
183+
if not msg.get("content"):
184+
msg["content"] = " "
185+
logging.warning(
186+
"Empty content in assistant message without tool_calls, replacing with space"
187+
)
188+
165189
# Get current configuration state
166190
active_catalog = get_active_catalog() or "Not set"
167191
active_schema = get_active_schema() or "Not set"
@@ -190,6 +214,10 @@ def process_with_tools(self, tools, max_iterations: int = 20):
190214
)
191215
# else: log warning or handle case where system message wasn't found initially
192216

217+
logging.debug(
218+
f"Iteration {iteration_count}: Sending {len(current_history)} messages to LLM"
219+
)
220+
193221
# Get the LLM response using the temporary, updated history
194222
response = self.llm_client.chat(
195223
messages=current_history, # Use the modified temporary history for the call
@@ -224,9 +252,23 @@ def process_with_tools(self, tools, max_iterations: int = 20):
224252
},
225253
}
226254
)
255+
256+
# Ensure content is not None or empty (required by some LLM APIs)
257+
# When making tool calls, content can be None, but we need to provide at least an empty string
258+
# or a placeholder to avoid "text content blocks must be non-empty" errors
259+
content = response_message.content
260+
if content is None or (
261+
isinstance(content, str) and not content.strip()
262+
):
263+
content = None # Use None for tool calls (OpenAI spec allows this)
264+
265+
logging.debug(
266+
f"Assistant tool call response - content: {repr(content)}, tool_calls: {len(tool_calls_list)}"
267+
)
268+
227269
assistant_msg = {
228270
"role": "assistant",
229-
"content": response_message.content,
271+
"content": content,
230272
"tool_calls": tool_calls_list,
231273
}
232274
self.conversation_history.append(assistant_msg)
@@ -309,15 +351,17 @@ def process_query(self, query):
309351
Returns:
310352
Final response from the LLM
311353
"""
312-
# If no system message exists, add the default one
354+
# If no system message exists, add the provider-aware one
313355
has_system = False
314356
for msg in self.conversation_history:
315357
if msg["role"] == "system":
316358
has_system = True
317359
break
318360

319361
if not has_system:
320-
self.add_system_message(DEFAULT_SYSTEM_MESSAGE)
362+
provider = get_data_provider()
363+
system_message = get_default_system_message(provider or "databricks")
364+
self.add_system_message(system_message)
321365

322366
# Add user message to history
323367
self.add_user_message(query)

chuck_data/agent/prompts/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from .default_system_prompt import DEFAULT_SYSTEM_MESSAGE
1+
from .default_system_prompt import DEFAULT_SYSTEM_MESSAGE, get_default_system_message
22
from .pii_prompts import PII_AGENT_SYSTEM_MESSAGE, BULK_PII_AGENT_SYSTEM_MESSAGE
33
from .stitch_prompts import STITCH_AGENT_SYSTEM_MESSAGE
44

55
__all__ = [
66
"DEFAULT_SYSTEM_MESSAGE",
7+
"get_default_system_message",
78
"PII_AGENT_SYSTEM_MESSAGE",
89
"BULK_PII_AGENT_SYSTEM_MESSAGE",
910
"STITCH_AGENT_SYSTEM_MESSAGE",

chuck_data/agent/prompts/default_system_prompt.py

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
1-
DEFAULT_SYSTEM_MESSAGE = """You are Chuck AI, a helpful Databricks agent that helps users work with Databricks resources.
1+
def get_default_system_message(provider: str = "databricks") -> str:
2+
"""
3+
Get the default system message for the agent based on the data provider.
4+
5+
Args:
6+
provider: Data provider ("databricks" or "aws_redshift")
7+
8+
Returns:
9+
System message string
10+
"""
11+
if provider == "aws_redshift":
12+
return REDSHIFT_SYSTEM_MESSAGE
13+
else:
14+
return DATABRICKS_SYSTEM_MESSAGE
15+
16+
17+
DATABRICKS_SYSTEM_MESSAGE = """You are Chuck AI, a helpful Databricks agent that helps users work with Databricks resources.
218
319
You can perform the following main features:
420
1. Navigate and explore Databricks Unity Catalog metadata (catalogs, schemas, tables)
@@ -23,11 +39,17 @@
2339
2440
2. PII and/or Customer data DETECTION: To help with PII and/or customer data scanning:
2541
- For single table: navigate to the right catalog/schema, then use tag_pii_columns
26-
- For bulk scanning: navigate to the right catalog/schema, then use scan_schema_for_pii
42+
- For bulk scanning: use scan_schema_for_pii with explicit catalog_name and schema_name parameters
43+
* When user says "scan catalog.schema" or "scan in schema catalog.schema", parse it as catalog_name=first_part, schema_name=second_part
44+
* Example: "scan main.public" means catalog_name="main", schema_name="public"
45+
* Example: "scan dev_catalog.sales" means catalog_name="dev_catalog", schema_name="sales"
46+
* If catalog/schema not specified, scan_schema_for_pii will use currently active catalog/schema
2747
2848
3. PII TAGGING: To help with bulk PII tagging across a schema:
29-
- If the catalog and schema are already selected - have the user select them first. PII tagging requires a catalog and schema to be selected.
30-
- If user asks about tagging PII, bulk tagging PII, or applying PII tags: use bulk_tag_pii
49+
- If user asks about tagging PII, bulk tagging PII, or applying PII tags: use bulk_tag_pii with explicit catalog_name and schema_name parameters
50+
* When user says "tag catalog.schema" or "tag in schema catalog.schema", parse it as catalog_name=first_part, schema_name=second_part
51+
* Example: "tag main.public" means catalog_name="main", schema_name="public"
52+
* If catalog/schema not specified, bulk_tag_pii will use currently active catalog/schema
3153
3254
4. STITCH INTEGRATION: To set up identity graph or customer 360 with Stitch:
3355
- If the catalog and schema are already selected - have the user select them first. Stitch requires a catalog and schema to be selected.
@@ -66,7 +88,71 @@
6688
6789
Be concise, practical, and focus on guiding users through Databricks effectively.
6890
69-
You are an agent - please keep going until the users query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved.
91+
You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved.
7092
7193
When you communicate, always use a chill tone. You should seem like a highly skilled data engineer with hippy vibes.
7294
"""
95+
96+
97+
REDSHIFT_SYSTEM_MESSAGE = """You are Chuck AI, a helpful AWS Redshift agent that helps users work with Redshift resources.
98+
99+
You can perform the following main features:
100+
1. Navigate and explore Redshift metadata (databases, schemas, tables)
101+
2. Identify and tag Personally Identifiable Information (PII) in database tables
102+
3. Provide information and guidance on how to use Redshift features
103+
104+
When a user asks a question:
105+
- If they're looking for specific data, help them navigate through databases, schemas, and tables
106+
- If they're asking about customer data or PII, guide them through the PII detection process
107+
- When displaying lists of resources (databases, schemas, tables, etc.), the output will be shown directly to the user
108+
109+
IMPORTANT WORKFLOWS:
110+
111+
1. DATABASES: To work with databases (Redshift uses databases instead of catalogs):
112+
- If user asks "what databases do I have?" or wants to see databases: use list_databases with display=true (shows full table)
113+
- If user asks to "use X database" or "switch to X database": DIRECTLY use select_database with database parameter (accepts name, has built-in fuzzy matching). DO NOT call list_databases first - select_database has built-in fuzzy matching and will find the database.
114+
- If you need database info for internal processing: use list_databases (defaults to no table display)
115+
116+
2. PII and/or Customer data DETECTION: To help with PII and/or customer data scanning:
117+
- Use scan_schema_for_pii with explicit database and schema_name parameters (or rely on active database/schema)
118+
* When user says "scan database.schema" or "scan in schema database.schema", parse it as database=first_part, schema_name=second_part
119+
* Example: "scan dev.public" means database="dev", schema_name="public"
120+
* Example: "scan prod_db.sales" means database="prod_db", schema_name="sales"
121+
* If database/schema not specified, scan_schema_for_pii will use currently active database/schema
122+
123+
3. PII TAGGING: To help with bulk PII tagging across a schema:
124+
- When user asks to tag PII, apply PII tags, label PII, or mark PII columns: use bulk_tag_pii command
125+
- bulk_tag_pii works with both explicit database/schema parameters OR uses the currently active database/schema
126+
- The command will scan for PII, show a preview, and ask for confirmation before applying tags
127+
- Examples:
128+
* User says "tag please" or "apply PII tags" → use bulk_tag_pii (will use active database/schema)
129+
* User says "tag dev.public" → use bulk_tag_pii with database="dev", schema_name="public"
130+
* User says "label customer data" → use bulk_tag_pii (will use active database/schema)
131+
- IMPORTANT: bulk_tag_pii is the ONLY way to tag PII columns in Redshift. There is no single-table tagging command.
132+
133+
4. SCHEMAS: To work with schemas:
134+
- If user asks "what schemas do I have?" or wants to see schemas: use list_schemas with display=true (shows full table)
135+
- If user asks to "use X schema" or "switch to X schema": use select_schema with schema parameter (accepts name, has built-in fuzzy matching). DO NOT call list_schemas first - select_schema has built-in fuzzy matching and will find the schema.
136+
- If you need schema info for internal processing: use list_schemas (defaults to no table display)
137+
138+
5. TABLES: To work with tables:
139+
- If user asks "what tables do I have?" or wants to see tables: use list_tables with display=true (shows full table)
140+
- If you need table info for internal processing: use list_tables (defaults to no table display)
141+
- Column counts are automatically included in table metadata
142+
143+
Some of the tools you can use require the user to select a database and/or schema first. If the user hasn't selected one YOU MUST ask them if they want help selecting a database and schema. DO NO OTHER ACTION
144+
145+
IMPORTANT: DO NOT use function syntax in your text response such as <function>...</function> or similar formats. The proper way to call tools is through the official OpenAI function calling interface which is handled by the system automatically. Just use the tools provided to you via the API and the system will handle the rest.
146+
147+
When tools display information directly to the user (like list_databases, list_tables, etc.), acknowledge what they're seeing and guide them on next steps based on the displayed information.
148+
149+
Be concise, practical, and focus on guiding users through Redshift effectively.
150+
151+
You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved.
152+
153+
When you communicate, always use a chill tone. You should seem like a highly skilled data engineer with hippy vibes.
154+
"""
155+
156+
157+
# Keep DEFAULT_SYSTEM_MESSAGE for backwards compatibility
158+
DEFAULT_SYSTEM_MESSAGE = DATABRICKS_SYSTEM_MESSAGE

chuck_data/agent/tool_executor.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,17 @@
1313
import logging
1414
import jsonschema # Requires jsonschema to be installed
1515

16+
from chuck_data.clients.databricks import DatabricksAPIClient
17+
from chuck_data.clients.redshift import RedshiftAPIClient
1618
from chuck_data.command_registry import get_command
1719
from chuck_data.command_registry import (
1820
get_agent_tool_schemas as get_command_registry_tool_schemas,
1921
)
2022
from chuck_data.commands.base import (
2123
CommandResult,
2224
) # For type hinting and checking handler result
23-
from chuck_data.clients.databricks import (
24-
DatabricksAPIClient,
25-
) # For type hinting api_client
26-
from typing import Dict, Any, Optional, List, Callable
25+
from chuck_data.config import get_data_provider
26+
from typing import Dict, Any, Optional, List, Callable, Union
2727
from jsonschema.exceptions import ValidationError
2828

2929
# The display_to_user utility and individual tool implementation functions
@@ -41,19 +41,20 @@
4141

4242
def get_tool_schemas() -> List[Dict[str, Any]]:
4343
"""Get all command schemas in agent tool format from the command registry."""
44-
return get_command_registry_tool_schemas()
44+
provider = get_data_provider()
45+
return get_command_registry_tool_schemas(provider)
4546

4647

4748
def execute_tool(
48-
api_client: Optional[DatabricksAPIClient],
49+
api_client: Optional[DatabricksAPIClient | RedshiftAPIClient],
4950
tool_name: str,
5051
tool_args: Dict[str, Any],
5152
output_callback: Optional[Callable[..., Any]] = None,
5253
) -> Dict[str, Any]:
5354
"""Execute a tool (command) by its name with the provided arguments.
5455
5556
Args:
56-
api_client: The Databricks API client, passed to the handler if needed.
57+
api_client: The API client (DatabricksAPIClient or RedshiftAPIClient), passed to the handler if needed.
5758
tool_name: The name of the tool (command) to execute.
5859
tool_args: A dictionary of arguments for the tool, parsed from LLM JSON.
5960
@@ -66,7 +67,12 @@ def execute_tool(
6667
f"Agent attempting to execute tool: {tool_name} with args: {tool_args}"
6768
)
6869

69-
command_def = get_command(tool_name)
70+
# Detect provider from client to route to the correct command
71+
from chuck_data.data_providers import get_provider_name_from_client
72+
73+
provider = get_provider_name_from_client(api_client)
74+
75+
command_def = get_command(tool_name, provider=provider)
7076

7177
if not command_def:
7278
logging.error(f"Agent tool '{tool_name}' not found in command registry.")

chuck_data/clients/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""API clients for external services."""
2+
3+
from chuck_data.clients.databricks import DatabricksAPIClient
4+
from chuck_data.clients.emr import EMRAPIClient
5+
from chuck_data.clients.redshift import RedshiftAPIClient
6+
7+
__all__ = ["DatabricksAPIClient", "EMRAPIClient", "RedshiftAPIClient"]

chuck_data/clients/amperity.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,61 @@ def get_job_status(self, job_id: str, token: str) -> dict:
264264
logging.error(f"Error getting job status: {e}")
265265
raise
266266

267+
def fetch_amperity_job_init(
268+
self, token: str, api_url: Optional[str] = None
269+
) -> dict:
270+
"""Fetch initialization script for Amperity jobs.
271+
272+
This method calls the Amperity /api/job/launch endpoint to get both the
273+
cluster init script and a job-id for tracking the Stitch job.
274+
275+
Args:
276+
token: Amperity authentication token
277+
api_url: Optional override for the job init endpoint (used in testing)
278+
279+
Returns:
280+
Dict containing:
281+
- 'cluster-init': The initialization script content
282+
- 'job-id': Job identifier for tracking
283+
284+
Raises:
285+
ValueError: If API returns an error or authentication fails
286+
ConnectionError: If connection to Amperity API fails
287+
"""
288+
try:
289+
headers = {
290+
"Authorization": f"Bearer {token}",
291+
"Content-Type": "application/json",
292+
}
293+
294+
if not api_url:
295+
api_url = f"https://{self.base_url}/api/job/launch"
296+
297+
response = requests.post(api_url, headers=headers, json={}, timeout=30)
298+
response.raise_for_status()
299+
return response.json()
300+
301+
except requests.exceptions.HTTPError as e:
302+
response = e.response
303+
resp_text = response.text if response else ""
304+
logging.debug(f"HTTP error: {e}, Response: {resp_text}")
305+
306+
if response is not None:
307+
try:
308+
message = response.json().get("message", resp_text)
309+
except ValueError:
310+
message = resp_text
311+
raise ValueError(
312+
f"{response.status_code} Error: {message}. Please /logout and /login again"
313+
)
314+
raise ValueError(
315+
f"HTTP error occurred: {e}. Please /logout and /login again"
316+
)
317+
318+
except requests.RequestException as e:
319+
logging.debug(f"Connection error: {e}")
320+
raise ConnectionError(f"Connection error occurred: {e}")
321+
267322
def record_job_submission(
268323
self, databricks_run_id: str, token: str, job_id: str
269324
) -> bool:

0 commit comments

Comments
 (0)