Skip to content
Merged
1 change: 1 addition & 0 deletions config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ llm_dataanalysis: #used only in data_analysis_agent
use_filter_step: true # Set to false to skip context filtering LLM call
climatemodel_name: "AWI_CM"
llmModeKey: "agent_llm" #"agent_llm" #"direct_llm"
analysis_mode: "smart" # fast | smart | deep — sets default tools and budgets (overridable per-toggle)
use_smart_agent: true
use_era5_data: true # Download ERA5 time series from CDS API (requires credentials)
use_destine_data: true # Download DestinE projections via HDA API (requires DESP credentials)
Expand Down
6 changes: 4 additions & 2 deletions src/climsight/climsight_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1022,8 +1022,10 @@ def prepare_predefined_data(state: AgentState):
}

def route_after_prepare(state: AgentState) -> str:
"""Route after prepare_predefined_data based on python_REPL config."""
if config.get('use_powerful_data_analysis', False):
"""Route after prepare_predefined_data based on analysis mode config."""
from data_analysis_agent import resolve_analysis_config
effective = resolve_analysis_config(config)
if effective.get('use_powerful_data_analysis', False):
return "data_analysis_agent"
else:
return "combine_agent"
Expand Down
215 changes: 160 additions & 55 deletions src/climsight/data_analysis_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,59 @@

logger = logging.getLogger(__name__)

# ── Analysis mode presets ────────────────────────────────────────────────
ANALYSIS_MODES = {
"fast": {
"use_powerful_data_analysis": False,
"use_era5_data": False,
"use_destine_data": False,
"use_smart_agent": False,
"hard_tool_limit": 10,
"ideal_tool_calls": "3-5",
"max_per_response": 3,
"max_reflect": 0,
"max_iterations": 8,
},
"smart": {
"use_powerful_data_analysis": True,
"use_era5_data": True,
"use_destine_data": False,
"use_smart_agent": True,
"hard_tool_limit": 50,
"ideal_tool_calls": "15-20",
"max_per_response": 5,
"max_reflect": 8,
"max_iterations": 30,
},
"deep": {
"use_powerful_data_analysis": True,
"use_era5_data": True,
"use_destine_data": True,
"use_smart_agent": True,
"hard_tool_limit": 150,
"ideal_tool_calls": "40-50",
"max_per_response": 10,
"max_reflect": 15,
"max_iterations": 80,
},
}


def resolve_analysis_config(config: dict) -> dict:
"""Merge analysis mode defaults with explicit config overrides.

The mode (fast/smart/deep) sets default values for toggles and budgets.
If the user explicitly sets a toggle in config or UI, that override wins.
"""
mode = config.get("analysis_mode", "smart")
defaults = ANALYSIS_MODES.get(mode, ANALYSIS_MODES["smart"]).copy()
# Explicit toggles in config override mode defaults
for key in ["use_powerful_data_analysis", "use_era5_data",
"use_destine_data", "use_smart_agent"]:
if key in config:
defaults[key] = config[key]
return defaults


def _build_climate_data_summary(df_list: List[Dict[str, Any]]) -> str:
"""Summarize available climatology without exposing raw values."""
Expand Down Expand Up @@ -119,31 +172,55 @@ def _build_filter_prompt() -> str:

def _create_tool_prompt(datasets_text: str, config: dict, lat: float = None, lon: float = None,
has_climate_data: bool = False, has_hazard_data: bool = False,
has_population_data: bool = False, has_era5_data: bool = False) -> str:
"""System prompt for tool-driven analysis - dynamically built based on config.
has_population_data: bool = False, has_era5_data: bool = False,
mode_config: dict = None) -> str:
"""System prompt for tool-driven analysis - dynamically built based on config and mode.

NOTE: This agent only runs when python_REPL is enabled (use_powerful_data_analysis=True).
ERA5 climatology and predefined plots are already generated by prepare_predefined_data.
"""
if mode_config is None:
mode_config = resolve_analysis_config(config)

has_era5_download = mode_config.get("use_era5_data", config.get("use_era5_data", False))
has_destine = mode_config.get("use_destine_data", config.get("use_destine_data", False))
has_python_repl = mode_config.get("use_powerful_data_analysis", True)

hard_limit = mode_config.get("hard_tool_limit", 30)
ideal_calls = mode_config.get("ideal_tool_calls", "6-7")
max_per_resp = mode_config.get("max_per_response", 4)
max_reflect = mode_config.get("max_reflect", 2)
has_era5_download = config.get("use_era5_data", False)
has_destine = config.get("use_destine_data", False)

# --- Build prompt without f-strings for code blocks to avoid brace escaping ---
sections = []

# ── ROLE ──────────────────────────────────────────────────────────────
sections.append(
role_lines = [
"You are ClimSight's data analysis agent.\n"
"Your job: provide ADDITIONAL quantitative climate analysis beyond the standard plots.\n"
"You have a persistent Python REPL, pre-extracted data files, and optional ERA5 download access.\n\n"
"CRITICAL EFFICIENCY RULES:\n"
"- HARD LIMIT: 30 tool calls total for the entire session.\n"
"- MAX 3-4 tool calls per response. Never fire 5+ tools in a single response.\n"
"- Write focused Python scripts — each one should accomplish a meaningful chunk of work.\n"
"- SEQUENTIAL ordering: first Python_REPL to generate plots, THEN reflect_on_image in a LATER response.\n"
" Never call reflect_on_image in the same response as Python_REPL.\n"
"- Ideal session: 3-4 REPL calls → 1-2 reflect calls → final answer. That is 6-7 tool calls total.\n"
)
]
if has_python_repl:
role_lines.append(
"You have a persistent Python REPL, pre-extracted data files, and optional ERA5 download access.\n\n"
"CRITICAL EFFICIENCY RULES:\n"
f"- HARD LIMIT: {hard_limit} tool calls total for the entire session.\n"
f"- MAX {max_per_resp} tool calls per response. Never fire {max_per_resp + 1}+ tools in a single response.\n"
"- Write focused Python scripts — each one should accomplish a meaningful chunk of work.\n"
"- SEQUENTIAL ordering: first Python_REPL to generate plots, THEN reflect_on_image in a LATER response.\n"
" Never call reflect_on_image in the same response as Python_REPL.\n"
f"- Ideal session: {ideal_calls} tool calls total.\n"
)
else:
role_lines.append(
"You have pre-extracted data files and predefined plots.\n\n"
"CRITICAL EFFICIENCY RULES:\n"
f"- HARD LIMIT: {hard_limit} tool calls total for the entire session.\n"
f"- MAX {max_per_resp} tool calls per response.\n"
f"- Ideal session: {ideal_calls} tool calls total.\n"
)
sections.append("".join(role_lines))

# ── 1. DATA ALREADY AVAILABLE ─────────────────────────────────────────
sections.append("## 1. DATA ALREADY IN THE SANDBOX (do not re-extract)\n")
Expand Down Expand Up @@ -255,6 +332,18 @@ def _create_tool_prompt(datasets_text: str, config: dict, lat: float = None, lon
if has_destine:
tools_list.append("- **search_destine_parameters** — find DestinE parameters via RAG search (see section 3b)")
tools_list.append("- **retrieve_destine_data** — download DestinE time series (see section 3b)")
if has_python_repl:
tools_list.append(
"- **Python_REPL** — execute Python code in a sandboxed environment.\n"
" All files are relative to the sandbox root.\n"
" The `results/` directory is pre-created for saving plots.\n"
" Datasets are pre-loaded into the sandbox (see paths below).\n"
" STRATEGY: DIVIDE AND CONQUER. Split your work into a few focused scripts,\n"
" each tackling ONE logical task (e.g., load+explore, then analyze+plot-set-1,\n"
" then analyze+plot-set-2). This avoids cascading errors from monolithic scripts.\n"
" But don't go overboard with tiny one-liner calls either — find a reasonable balance.\n"
" Each script should be self-contained: import what it needs, do meaningful work, print results."
)
tools_list.append(
"- **Python_REPL** — execute Python code in a sandboxed environment.\n"
" All files are relative to the sandbox root.\n"
Expand All @@ -268,15 +357,17 @@ def _create_tool_prompt(datasets_text: str, config: dict, lat: float = None, lon
)
tools_list.append("- **list_plotting_data_files** — discover files in sandbox directories")
tools_list.append("- **image_viewer** — view and analyze plots in `results/` (use relative paths)")
tools_list.append(
"- **reflect_on_image** — get quality feedback on a plot you created.\n"
" Call once per plot — reflect on ALL generated plots, not just one.\n"
" MUST be called in a SEPARATE response AFTER the Python_REPL that created the plots.\n"
" Always verify the file exists (via os.path.exists in REPL) BEFORE calling this tool.\n"
" MINIMUM ACCEPTABLE SCORE: 7/10. If score < 7, you MUST re-plot with fixes applied.\n"
" Read the fix suggestions from the reviewer and apply them in your next REPL call."
)
tools_list.append("- **wise_agent** — ask for visualization strategy advice before coding")
if has_python_repl and max_reflect > 0:
tools_list.append(
"- **reflect_on_image** — get quality feedback on a plot you created.\n"
" Call once per plot — reflect on ALL generated plots, not just one.\n"
" MUST be called in a SEPARATE response AFTER the Python_REPL that created the plots.\n"
" Always verify the file exists (via os.path.exists in REPL) BEFORE calling this tool.\n"
" MINIMUM ACCEPTABLE SCORE: 7/10. If score < 7, you MUST re-plot with fixes applied.\n"
" Read the fix suggestions from the reviewer and apply them in your next REPL call."
)
if has_python_repl:
tools_list.append("- **wise_agent** — ask for visualization strategy advice before coding")
sections.append("\n".join(tools_list) + "\n")

# ── 5. WORKFLOW ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -444,29 +535,40 @@ def _create_tool_prompt(datasets_text: str, config: dict, lat: float = None, lon
)

# ── TOOL BUDGET ───────────────────────────────────────────────────────
sections.append(
"\n## TOOL BUDGET (HARD LIMIT: 30 tool calls total, max 3-4 per response)\n\n"
"Plan your session carefully — you have at most 30 tool calls:\n"
"- Python_REPL: a few calls, each focused on ONE logical task\n"
"- retrieve_era5_data: 0-3 calls (one per variable: t2, cp, lsp)\n"
"- search_destine_parameters: 1-2 calls (find param_ids before downloading)\n"
"- retrieve_destine_data: 0-3 calls (use full 2020-2039 range by default)\n"
"- reflect_on_image: one call per plot — QA ALL generated plots, not just one\n"
"- list_plotting_data_files / image_viewer: 0-2 calls\n"
"- wise_agent: 0-1 calls\n\n"
"DIVIDE AND CONQUER — Python_REPL strategy:\n"
"- Script 1: Load ALL data, explore structure, print column names and shapes\n"
"- Script 2: Climatology analysis + comparison plots (temp, precip, wind)\n"
"- Script 3: Threshold/risk analysis + additional plots (if ERA5 time series available)\n"
"- Script 4 (if needed): Fix any errors from previous scripts, create missing plots\n\n"
"WHY: One massive all-in-one script causes cascading errors — one bug kills everything.\n"
"Splitting into reasonable chunks lets you catch and fix errors between steps.\n\n"
"ANTI-SPAM RULES:\n"
"- Never call more than 3-4 tools in a single response.\n"
"- Never call reflect_on_image in the same response as Python_REPL.\n"
"- Never call reflect_on_image more than twice total.\n"
"- Don't spam tiny one-liner REPL calls — each script should do meaningful work.\n"
)
budget_lines = [
f"\n## TOOL BUDGET (HARD LIMIT: {hard_limit} tool calls total, max {max_per_resp} per response)\n\n"
f"Plan your session carefully — you have at most {hard_limit} tool calls:\n"
]
if has_python_repl:
budget_lines.append("- Python_REPL: a few calls, each focused on ONE logical task\n")
if has_era5_download:
budget_lines.append("- retrieve_era5_data: 0-3 calls (one per variable: t2, cp, lsp)\n")
if has_destine:
budget_lines.append("- search_destine_parameters: 1-2 calls (find param_ids before downloading)\n")
budget_lines.append("- retrieve_destine_data: 0-3 calls (use full 2020-2039 range by default)\n")
if max_reflect > 0:
budget_lines.append(f"- reflect_on_image: one call per plot, max {max_reflect} total — QA ALL generated plots\n")
budget_lines.append("- list_plotting_data_files / image_viewer: 0-2 calls\n")
budget_lines.append("- wise_agent: 0-1 calls\n\n")

if has_python_repl:
budget_lines.append(
"DIVIDE AND CONQUER — Python_REPL strategy:\n"
"- Script 1: Load ALL data, explore structure, print column names and shapes\n"
"- Script 2: Climatology analysis + comparison plots (temp, precip, wind)\n"
"- Script 3: Threshold/risk analysis + additional plots (if ERA5 time series available)\n"
"- Script 4 (if needed): Fix any errors from previous scripts, create missing plots\n\n"
"WHY: One massive all-in-one script causes cascading errors — one bug kills everything.\n"
"Splitting into reasonable chunks lets you catch and fix errors between steps.\n\n"
)

if has_python_repl and max_reflect > 0:
budget_lines.append("- Never call reflect_on_image in the same response as Python_REPL.\n")
budget_lines.append(f"- Never call reflect_on_image more than {max_reflect} times total.\n")
if has_python_repl:
budget_lines.append("- Don't spam tiny one-liner REPL calls — each script should do meaningful work.\n")

sections.append("".join(budget_lines))

return "\n".join(sections)

Expand Down Expand Up @@ -599,30 +701,33 @@ def data_analysis_agent(
except (ValueError, TypeError):
lat, lon = None, None

# Resolve effective config from analysis mode + overrides
effective = resolve_analysis_config(config)
analysis_mode = config.get("analysis_mode", "smart")
logger.info(f"data_analysis_agent: mode={analysis_mode}, effective={effective}")

# Tool setup for data_analysis_agent
# NOTE: This agent only runs when python_REPL is enabled (use_powerful_data_analysis=True)
# ERA5 climatology and predefined plots are already generated by prepare_predefined_data
tools = []

has_era5_data = config.get("era5_climatology", {}).get("enabled", True)
# This agent only runs when use_powerful_data_analysis=True, so Python REPL is always available
has_python_repl = True
has_python_repl = effective.get("use_powerful_data_analysis", True)

# NOTE: ERA5 climatology and predefined plots are ALREADY generated by prepare_predefined_data
# The agent should use the pre-extracted data, not re-extract it
logger.info(f"data_analysis_agent starting - predefined plots: {state.predefined_plots}")
logger.info(f"ERA5 climatology available: {bool(state.era5_climatology_response)}")

# 3. ERA5 time series retrieval (if enabled - for detailed year-by-year analysis)
if config.get("use_era5_data", False):
# 3. ERA5 time series retrieval (if enabled)
if effective.get("use_era5_data", False):
arraylake_api_key = config.get("arraylake_api_key", "")
if arraylake_api_key:
tools.append(create_era5_retrieval_tool(arraylake_api_key))
else:
logger.warning("ERA5 data enabled but no arraylake_api_key in config. ERA5 retrieval tool not added.")

# 3b. DestinE parameter search + data retrieval (if enabled)
if config.get("use_destine_data", False):
if effective.get("use_destine_data", False):
tools.append(create_destine_search_tool(config))
tools.append(create_destine_retrieval_tool())

Expand All @@ -639,17 +744,15 @@ def data_analysis_agent(
tools.append(list_plotting_data_files_tool)

# 6. Image viewer - ALWAYS available (plots are saved to results/ folder)
# Works with predefined plots even when Python REPL is disabled
vision_model = config.get("llm_combine", {}).get("model_name", "gpt-4o")
image_viewer_tool = create_image_viewer_tool(
openai_api_key=api_key,
model_name=vision_model,
sandbox_path=state.results_dir # Point to results folder where plots are saved
sandbox_path=state.results_dir
)
tools.append(image_viewer_tool)

# 7. Image reflection and wise_agent - ONLY when Python REPL is enabled
# (these tools are for evaluating/creating visualizations)
if has_python_repl:
tools.append(reflect_tool)
tools.append(wise_agent_tool)
Expand All @@ -660,7 +763,8 @@ def data_analysis_agent(
has_climate_data=bool(state.df_list),
has_hazard_data=state.hazard_data is not None,
has_population_data=bool(state.population_config),
has_era5_data=bool(state.era5_climatology_response)
has_era5_data=bool(state.era5_climatology_response),
mode_config=effective,
)

if llm_dataanalysis_agent is None:
Expand All @@ -671,11 +775,12 @@ def data_analysis_agent(
model_name=config.get("llm_combine", {}).get("model_name", "gpt-4.1-nano"),
)

max_iterations = effective.get("max_iterations", 20)
agent_executor = create_standard_agent_executor(
llm_dataanalysis_agent,
tools,
tool_prompt,
max_iterations=20,
max_iterations=max_iterations,
)

agent_input = {
Expand Down
Loading