diff --git a/config.yml b/config.yml index e12a1dc..67a183a 100644 --- a/config.yml +++ b/config.yml @@ -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) diff --git a/src/climsight/climsight_engine.py b/src/climsight/climsight_engine.py index 566d581..3dcb61a 100644 --- a/src/climsight/climsight_engine.py +++ b/src/climsight/climsight_engine.py @@ -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" diff --git a/src/climsight/data_analysis_agent.py b/src/climsight/data_analysis_agent.py index 8affc1d..16b55e6 100644 --- a/src/climsight/data_analysis_agent.py +++ b/src/climsight/data_analysis_agent.py @@ -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.""" @@ -119,12 +172,24 @@ 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) @@ -132,18 +197,30 @@ def _create_tool_prompt(datasets_text: str, config: dict, lat: float = None, lon 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") @@ -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" @@ -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 ─────────────────────────────────────────────────────── @@ -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) @@ -599,22 +701,25 @@ 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)) @@ -622,7 +727,7 @@ def data_analysis_agent( 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()) @@ -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) @@ -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: @@ -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 = { diff --git a/src/climsight/streamlit_interface.py b/src/climsight/streamlit_interface.py index a1d4ce3..d10bfd5 100644 --- a/src/climsight/streamlit_interface.py +++ b/src/climsight/streamlit_interface.py @@ -104,6 +104,62 @@ def run_streamlit(config, api_key='', skip_llm_call=False, rag_activated=True, r # normalize longitude in case map has been move around global (more than) once lon_default = normalize_longitude(lon_default) + # --- Analysis mode selector (outside form so toggles update immediately) --- + from data_analysis_agent import ANALYSIS_MODES + mode_options = ["fast", "smart", "deep"] + default_mode = config.get("analysis_mode", "smart") + if default_mode not in mode_options: + default_mode = "smart" + + def _on_mode_change(): + """Sync toggle states when analysis mode changes.""" + mode = st.session_state["analysis_mode_radio"] + defaults = ANALYSIS_MODES[mode] + st.session_state["toggle_smart_agent"] = defaults["use_smart_agent"] + st.session_state["toggle_era5"] = defaults["use_era5_data"] + st.session_state["toggle_destine"] = defaults["use_destine_data"] + st.session_state["toggle_python"] = defaults["use_powerful_data_analysis"] + + # Initialize toggle defaults on first run + if "toggle_smart_agent" not in st.session_state: + init_defaults = ANALYSIS_MODES[default_mode] + st.session_state["toggle_smart_agent"] = init_defaults["use_smart_agent"] + st.session_state["toggle_era5"] = init_defaults["use_era5_data"] + st.session_state["toggle_destine"] = init_defaults["use_destine_data"] + st.session_state["toggle_python"] = init_defaults["use_powerful_data_analysis"] + + analysis_mode = st.radio( + "Analysis mode", + options=mode_options, + index=mode_options.index(default_mode), + horizontal=True, + help="fast: pre-computed data only | smart: + Python REPL + ERA5 | deep: + DestinE projections", + key="analysis_mode_radio", + on_change=_on_mode_change, + ) + + mcol1, mcol2, mcol3, mcol4 = st.columns(4) + smart_agent = mcol1.toggle( + "Extra search", + key="toggle_smart_agent", + help="Additional Wikipedia/RAG requests (can increase response time).", + ) + use_era5_data = mcol2.toggle( + "ERA5 data", + key="toggle_era5", + help="Allow the data analysis agent to retrieve ERA5 data.", + ) + use_destine_data = mcol3.toggle( + "DestinE data", + key="toggle_destine", + help="DestinE Climate DT projections (SSP3-7.0, 82 parameters).", + ) + use_powerful_data_analysis = mcol4.toggle( + "Python analysis", + key="toggle_python", + help="Python REPL for custom analysis and plots.", + ) + # Wrap the input fields and the submit button in a form with st.form(key='my_form'): user_message = st.text_input( @@ -136,7 +192,7 @@ def run_streamlit(config, api_key='', skip_llm_call=False, rag_activated=True, r if 'gpt' in config['llm_combine']['model_name']: config['llm_combine']['model_type'] = "openai" else: - config['llm_combine']['model_type'] = "local" + config['llm_combine']['model_type'] = "local" with col1: # Always show additional information (removed toggle per user request) show_add_info = True @@ -254,6 +310,7 @@ def run_streamlit(config, api_key='', skip_llm_call=False, rag_activated=True, r # Update config with the selected LLM mode #config['llmModeKey'] = "direct_llm" if llmModeKey_box == "Direct" else "agent_llm" config['show_add_info'] = show_add_info + config['analysis_mode'] = analysis_mode config['use_smart_agent'] = smart_agent config['use_era5_data'] = use_era5_data config['use_destine_data'] = use_destine_data @@ -288,6 +345,7 @@ def run_streamlit(config, api_key='', skip_llm_call=False, rag_activated=True, r # Update config with the selected LLM mode #config['llmModeKey'] = "direct_llm" if llmModeKey_box == "Direct" else "agent_llm" config['show_add_info'] = show_add_info + config['analysis_mode'] = analysis_mode config['use_smart_agent'] = smart_agent config['use_era5_data'] = use_era5_data config['use_destine_data'] = use_destine_data