towardsinnovationlab commited on
Commit
21fb07c
·
verified ·
1 Parent(s): 105cfcc

Upload app_risk_report.py

Browse files
Files changed (1) hide show
  1. app_risk_report.py +256 -30
app_risk_report.py CHANGED
@@ -1475,7 +1475,7 @@ def _build_benchmark_md(forecast_model: str, forecast_data: dict,
1475
  # Architecture:
1476
  # - Python detects trigger conditions deterministically (forecast RMSE + Winkler ratio,
1477
  # satellite scene count) and ranks forecast alternatives from diagnostics.
1478
- # - Historical non-stationarity is retained as evidence, not treated as a failure.
1479
  # - Satellite retries preserve the same variable and widen the evidence window/cloud threshold.
1480
  # - GPT is used only for the forecast fallback judgment and final narrative.
1481
  # - Hard caps: MAX_LOOPS_PER_AGENT=2 (satellite), REACT_FORECAST_MAX_RUNS=3, GLOBAL_LOOP_CAP=8.
@@ -2263,36 +2263,88 @@ def _satellite_full_for_report(location, variable, days, max_cloud, spectral_ind
2263
  return satellite_data, sat_outputs
2264
 
2265
 
2266
- def _react_weather_agent(location, variable, days, openai_api_key, react_log):
2267
- """Historical non-stationarity is evidence, not a retry failure."""
2268
- current_days = int(days)
2269
- thought = (
2270
- f"Running historical climate evidence for {location} / {variable} "
2271
- f"with the requested {current_days}-day recent window."
2272
- )
2273
- action = f"weather_data_analyst(location={location}, variable={variable}, days={current_days})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2274
 
2275
- wx_outputs = analyze_weather_ui(location, variable, current_days)
2276
- weather_data = _weather_data_from_ui_outputs(wx_outputs)
2277
 
2278
- error = weather_data.get("error", "")
2279
- status_text = weather_data.get("status", "")
2280
- if status_text.startswith("❌") or status_text.startswith("Error:") or status_text.startswith("Failed"):
2281
- error = error or status_text
2282
- stat_conc = weather_data.get("stationarity_conclusion", "")
 
 
 
 
2283
  if error:
2284
- weather_data["error"] = error
2285
- observation = f"Historical evidence unavailable: {error}."
2286
- elif _is_nonstationary(stat_conc):
2287
- observation = (
2288
- "Non-stationarity detected and retained as a genuine time-series diagnostic; "
2289
- "no window escalation is triggered."
2290
- )
2291
- else:
2292
- observation = "Historical evidence available; no retry required."
2293
 
2294
- _react_step(react_log, "WeatherAgent", thought, action, observation)
2295
- return weather_data, wx_outputs, current_days
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2296
 
2297
 
2298
  def _react_satellite_agent(location, sat_variable, days, max_cloud, openai_api_key, react_log):
@@ -2543,6 +2595,141 @@ def format_react_log(react_log: list) -> str:
2543
  ]
2544
  return "\n".join(lines)
2545
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2546
  def generate_risk_report_ui(
2547
  location, variable, forecast_model, days, satellite_days, max_cloud, sat_variable
2548
  ):
@@ -2777,6 +2964,31 @@ def generate_risk_report_ui(
2777
  forecast_evidence = forecast_data.get("structured_evidence") or "Unavailable"
2778
  consistency = satellite_data.get("metadata_spectral_consistency", [])
2779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2780
  synthesis_prompt = f"""
2781
  You are a senior climate-risk analyst. Produce a concise, evidence-grounded report for **{location}**.
2782
 
@@ -2795,6 +3007,9 @@ Recent window: {final_days} days
2795
  {historical_evidence}
2796
  Diagnostic stationarity note: {weather_data.get('stationarity_conclusion', 'N/A')}
2797
  Historical data error: {weather_data.get('error', 'none')}
 
 
 
2798
 
2799
  ## SATELLITE INDEX GLOSSARY — PLAIN LANGUAGE
2800
  - NDVI (Normalized Difference Vegetation Index): vegetation greenness/health; higher generally means denser, more active vegetation.
@@ -2842,6 +3057,13 @@ Each row aligns the app forecast and Open-Meteo operational reference on the sam
2842
  Use these rows exactly in the 7-Day Outlook; do not recalculate the differences.
2843
  {json.dumps(daily_outlook_comparison, ensure_ascii=False)}
2844
 
 
 
 
 
 
 
 
2845
  ## E) RECENT / UPCOMING EXTERNAL EVENT INTELLIGENCE
2846
  {str(pplx_news_result.get('content') or 'External event intelligence unavailable.')[:1600]}
2847
 
@@ -2980,7 +3202,11 @@ For Data Quality, Uncertainty & Scope use exactly four bullets: Forecast reliabi
2980
  if missing_report.strip():
2981
  gpt_report = gpt_report.rstrip() + "\n\n---\n\n" + missing_report.strip()
2982
 
2983
- status_log[-1] = "✅ [6/6] GPT synthesis complete. Hazard-focused report ready."
 
 
 
 
2984
  except Exception as exc:
2985
  status_log[-1] = f"⚠️ [6/6] GPT error: {exc}"
2986
  gpt_report = f"GPT synthesis failed: {exc}"
@@ -3013,7 +3239,7 @@ with gr.Blocks() as iface_report:
3013
  gr.Markdown(
3014
  "Orchestrates Historical, Sentinel-2 and Forecast agents, compares the statistical "
3015
  "forecast with **Open-Meteo as an operational reference forecast**, and queries "
3016
- "**Perplexity** for recent/upcoming extreme-event intelligence. GPT synthesises the "
3017
  "already-structured evidence; it does not create numeric risk scores.\n\n"
3018
  "_The report is hazard-focused: exposure and vulnerability are not modelled. "
3019
  "OpenAI and Perplexity credentials are loaded server-side from Hugging Face Space Secrets._"
@@ -3078,7 +3304,7 @@ with gr.Blocks() as iface_report:
3078
  rp_forecast_summary = gr.State("")
3079
 
3080
  with gr.Tabs():
3081
- with gr.Tab("📋 Hazard-Focused Report (GPT)"):
3082
  rp_gpt_report = gr.Markdown(label="Full Report")
3083
 
3084
  with gr.Tab("📡 Operational Reference (Open-Meteo)"):
 
1475
  # Architecture:
1476
  # - Python detects trigger conditions deterministically (forecast RMSE + Winkler ratio,
1477
  # satellite scene count) and ranks forecast alternatives from diagnostics.
1478
+ # - Historical non-stationarity is retained as evidence; missing historical outputs trigger bounded retries.
1479
  # - Satellite retries preserve the same variable and widen the evidence window/cloud threshold.
1480
  # - GPT is used only for the forecast fallback judgment and final narrative.
1481
  # - Hard caps: MAX_LOOPS_PER_AGENT=2 (satellite), REACT_FORECAST_MAX_RUNS=3, GLOBAL_LOOP_CAP=8.
 
2263
  return satellite_data, sat_outputs
2264
 
2265
 
2266
+ def _extract_weather_anomalies(evidence: str, variable: str) -> list[dict]:
2267
+ """Surface explicit anomaly statements without inventing thresholds or measurements."""
2268
+ import re
2269
+
2270
+ signals = ("anomal", "unusual", "exceptional", "extreme", "record", "above normal",
2271
+ "below normal", "above average", "below average", "departure", "deviation")
2272
+ negations = ("no anomaly", "no anomalies", "no significant anomal", "without anomal",
2273
+ "not anomalous", "no unusual", "no extreme")
2274
+ findings = []
2275
+ seen = set()
2276
+ # Interpret each sentence/line once; never repeat a remote data request.
2277
+ for fragment in re.split(r"[\n]+|(?<=[.!?])\s+", str(evidence or "")):
2278
+ statement = fragment.strip().lstrip("-* ")
2279
+ lower = statement.lower()
2280
+ if not statement or not any(token in lower for token in signals):
2281
+ continue
2282
+ if any(negative in lower for negative in negations):
2283
+ continue
2284
+ key = lower.casefold()
2285
+ if key in seen:
2286
+ continue
2287
+ seen.add(key)
2288
+ findings.append({
2289
+ "variable": variable,
2290
+ "finding": statement[:600],
2291
+ "source": "NASA POWER structured climate evidence",
2292
+ "status": "reported_signal_not_independently_verified",
2293
+ })
2294
+ return findings[:12]
2295
 
 
 
2296
 
2297
+ def _react_weather_agent(location, variable, days, openai_api_key, react_log):
2298
+ """Fixed-window observation followed by non-blocking anomaly interpretation."""
2299
+ selected_days = int(days)
2300
+ action = (f"weather_data_analyst(location={location}, variable={variable}, "
2301
+ f"days={selected_days})")
2302
+ wx_outputs = analyze_weather_ui(location, variable, selected_days)
2303
+ data = _weather_data_from_ui_outputs(wx_outputs)
2304
+ status = str(data.get("status") or "")
2305
+ error = status if status.lstrip().startswith(("❌", "Error:", "Failed")) else ""
2306
  if error:
2307
+ data["error"] = error
 
 
 
 
 
 
 
 
2308
 
2309
+ checks = {
2310
+ "structured_evidence": bool(str(data.get("structured_evidence") or "").strip()),
2311
+ "data_preview": bool(data.get("data_preview")),
2312
+ "statistical_tests": bool(data.get("stats_table")),
2313
+ "stationarity_diagnostic": bool(str(data.get("stationarity_conclusion") or "").strip()),
2314
+ }
2315
+ alerts = []
2316
+ for name, available in checks.items():
2317
+ if not available:
2318
+ alerts.append(f"Unavailable diagnostic: {name}")
2319
+ _react_step(react_log, "WeatherAgent", "Retrieve historical evidence once, keeping the requested window.",
2320
+ action, "Historical evidence received." if not error else f"Service issue: {error}")
2321
+
2322
+ # Bounded local interpretation loop: identify, classify, report; no API retries.
2323
+ anomalies = _extract_weather_anomalies(data.get("structured_evidence"), variable)
2324
+ for finding in anomalies:
2325
+ _react_step(react_log, "WeatherAgent", "Review explicit climate anomaly evidence.",
2326
+ "inspect_structured_evidence", finding["finding"])
2327
+ if anomalies:
2328
+ alerts.append(f"{len(anomalies)} potential weather anomaly signal(s) in structured evidence.")
2329
+ conclusion = str(data.get("stationarity_conclusion") or "")
2330
+ if _is_nonstationary(conclusion):
2331
+ alerts.append("Non-stationarity reported by historical diagnostics (not itself a weather anomaly).")
2332
+ if error:
2333
+ alerts.append(f"Historical service unavailable: {error}")
2334
+
2335
+ data["weather_anomalies"] = anomalies
2336
+ data["weather_alerts"] = alerts
2337
+ data["evidence_quality"] = {
2338
+ **checks,
2339
+ "complete": all(checks.values()) and not bool(error),
2340
+ "selected_window_days": selected_days,
2341
+ "attempted_windows_days": [selected_days],
2342
+ "non_blocking": True,
2343
+ "anomaly_detection_method": "explicit statements in upstream structured evidence",
2344
+ }
2345
+ _react_step(react_log, "WeatherAgent", "Report anomalies and continue downstream.",
2346
+ "report_weather_anomalies", "; ".join(alerts) if alerts else "No explicit anomaly signals; continue.")
2347
+ return data, wx_outputs, selected_days
2348
 
2349
 
2350
  def _react_satellite_agent(location, sat_variable, days, max_cloud, openai_api_key, react_log):
 
2595
  ]
2596
  return "\n".join(lines)
2597
 
2598
+ def _report_visual_evidence(wx_outputs, sat_outputs, fc_outputs, openai_api_key):
2599
+ """Inspect actual chart pixels with a multimodal model, not only numeric captions."""
2600
+ import base64
2601
+ import io
2602
+ import requests
2603
+ from PIL import Image
2604
+ charts = [
2605
+ ("Historical climate context", wx_outputs, 2),
2606
+ ("Historical anomalies", wx_outputs, 3),
2607
+ ("Satellite true-colour scene", sat_outputs, 2),
2608
+ ("Satellite cloud distribution", sat_outputs, 4),
2609
+ ("Satellite land cover", sat_outputs, 5),
2610
+ ("Satellite spectral false-colour gallery", sat_outputs, 12),
2611
+ ("Satellite spectral correlation", sat_outputs, 16),
2612
+ ("Satellite spectral time series", sat_outputs, 14),
2613
+ ("Satellite spectral deviations", sat_outputs, 15),
2614
+ ("Forecast history and outlook", fc_outputs, 4),
2615
+ ("Forecast backtest", fc_outputs, 7),
2616
+ ]
2617
+ content = [{"type": "input_text", "text": (
2618
+ "Inspect the attached climate plots as visual evidence. For each chart, describe "
2619
+ "the actual visual pattern (direction, timing, peaks, anomalies, intervals, gaps), "
2620
+ "distinguish what is visually supported from uncertain interpretation, and "
2621
+ "identify any disagreement between charts. For satellite true-colour and false-colour "
2622
+ "imagery, distinguish visible water, vegetation, exposed soil, snow and clouds "
2623
+ "only when supported by the image and spectral indices. Explain spatial patterns, "
2624
+ "possible land-cover change and image-quality limitations (clouds, sampling, resolution), "
2625
+ "without treating colour alone as a verified land-cover classification. "
2626
+ "Do not infer flood, fire, drought, damage or a temporal change from a single scene. "
2627
+ "Cross-check visual impressions against NDVI, NDWI, NDSI, BSI, metadata and dates. "
2628
+ "Do not invent chart values, "
2629
+ "geographical attribution, causal claims or official warnings. "
2630
+ "Return concise observations with the chart titles."
2631
+ )}]
2632
+ included = []
2633
+ for title, outputs, index in charts:
2634
+ if not outputs or len(outputs) <= index:
2635
+ continue
2636
+ raw = outputs[index]
2637
+ if raw is None:
2638
+ continue
2639
+ try:
2640
+ import numpy as np
2641
+ if isinstance(raw, np.ndarray):
2642
+ img = Image.fromarray(raw.astype('uint8')).convert('RGB')
2643
+ elif isinstance(raw, str):
2644
+ img = Image.open(raw).convert('RGB')
2645
+ else:
2646
+ continue
2647
+ img.thumbnail((1100, 750))
2648
+ buffer = io.BytesIO()
2649
+ img.save(buffer, format='JPEG', quality=76)
2650
+ encoded = base64.b64encode(buffer.getvalue()).decode('ascii')
2651
+ content.append({"type": "input_text", "text": "Chart: " + title})
2652
+ content.append({"type": "input_image", "image_url": "data:image/jpeg;base64," + encoded})
2653
+ included.append(title)
2654
+ except (OSError, ValueError, TypeError):
2655
+ continue
2656
+ if not included:
2657
+ return {"observations": "Visual inspection unavailable: no chart images were accessible.", "charts": []}
2658
+ try:
2659
+ response = requests.post(
2660
+ 'https://api.openai.com/v1/responses',
2661
+ headers={'Authorization': f'Bearer {openai_api_key}', 'Content-Type': 'application/json'},
2662
+ json={'model': 'gpt-4.1', 'input': [{'role': 'user', 'content': content}],
2663
+ 'max_output_tokens': 1100}, timeout=120,
2664
+ )
2665
+ response.raise_for_status()
2666
+ payload = response.json()
2667
+ observations = '\n'.join(
2668
+ part.get('text', '') for block in payload.get('output', [])
2669
+ for part in block.get('content', []) if part.get('type') == 'output_text'
2670
+ )
2671
+ return {"observations": observations or 'Visual analysis returned no text.', "charts": included}
2672
+ except requests.RequestException as exc:
2673
+ return {"observations": f'Visual inspection unavailable: {exc}', "charts": included}
2674
+
2675
+
2676
+ def _report_react_review(draft, source_prompt, visual_evidence, alert_result, openai_api_key, react_log):
2677
+ """Bounded ReAct editorial review: inspect, critique, revise, then stop."""
2678
+ current = draft
2679
+ for iteration in range(1, 3):
2680
+ review_prompt = (
2681
+ 'Review this climate report as an independent scientific editor. Check factual '
2682
+ 'grounding, interpretation of actual figures, specific official alert sources and '
2683
+ 'dates, daily forecast alignment, uncertainty, actionable implications and all '
2684
+ 'eight requested sections. No fabricated alerts or image measurements. '
2685
+ 'Return ONLY JSON with keys verdict (accept or revise), issues (array of strings), '
2686
+ 'and priority (one string).\n'
2687
+ 'VISUAL EVIDENCE: ' + str(visual_evidence)[:6000] + '\n'
2688
+ 'WEB ALERTS: ' + str(alert_result)[:4500] + '\n'
2689
+ 'REPORT: ' + current[:18000]
2690
+ )
2691
+ try:
2692
+ raw = _call_gpt(review_prompt, openai_api_key,
2693
+ system='You are a critical, evidence-based climate report reviewer. Return JSON only.',
2694
+ max_output_tokens=1300)
2695
+ parsed = json.loads(raw.strip().removeprefix('```json').removeprefix('```').removesuffix('```').strip())
2696
+ except (ValueError, TypeError, KeyError) as exc:
2697
+ react_log.append({'agent': 'ReportingAgent', 'thought': 'Review response not parseable',
2698
+ 'action': 'accept_with_review_limit', 'observation': str(exc)})
2699
+ break
2700
+ issues = parsed.get('issues', [])
2701
+ verdict = parsed.get('verdict', 'revise')
2702
+ react_log.append({'agent': 'ReportingAgent', 'thought': f'Editorial review {iteration}',
2703
+ 'action': 'accept' if verdict == 'accept' else 'revise_draft',
2704
+ 'observation': '; '.join(map(str, issues))[:1000] or 'No material issues found'})
2705
+ if verdict == 'accept' or not issues:
2706
+ break
2707
+ revision_prompt = (
2708
+ 'Revise the following climate report to resolve the editorial findings. '
2709
+ 'Preserve the eight-section structure and all grounded quantitative values. '
2710
+ 'Explain observed visual patterns and dated web alerts only where verified; '
2711
+ 'never invent a chart value, a warning or a source URL. '
2712
+ 'Avoid merely listing numbers: explain implications and contradictions. '
2713
+ 'Return the COMPLETE revised Markdown report.\n'
2714
+ 'ISSUES: ' + json.dumps(issues, ensure_ascii=False) + '\n'
2715
+ 'VISUAL EVIDENCE: ' + str(visual_evidence)[:6000] + '\n'
2716
+ 'ALERT SOURCES: ' + str(alert_result)[:4500] + '\n'
2717
+ 'ORIGINAL SOURCE CONTEXT: ' + source_prompt[:12500] + '\n'
2718
+ 'CURRENT REPORT: ' + current[:18000]
2719
+ )
2720
+ try:
2721
+ revised = _call_gpt(revision_prompt, openai_api_key,
2722
+ system='You are a climate-risk reporting agent. Preserve facts and citations.',
2723
+ max_output_tokens=5600)
2724
+ if revised.strip():
2725
+ current = revised
2726
+ except Exception as exc:
2727
+ react_log.append({'agent': 'ReportingAgent', 'thought': 'Revision failed',
2728
+ 'action': 'retain_previous_draft', 'observation': str(exc)})
2729
+ break
2730
+ return current
2731
+
2732
+
2733
  def generate_risk_report_ui(
2734
  location, variable, forecast_model, days, satellite_days, max_cloud, sat_variable
2735
  ):
 
2964
  forecast_evidence = forecast_data.get("structured_evidence") or "Unavailable"
2965
  consistency = satellite_data.get("metadata_spectral_consistency", [])
2966
 
2967
+ # Reporting Agent tools: inspect real chart pixels and evaluate web-alert coverage.
2968
+ status_log.append("🔎 [ReportingAgent] Inspecting charts and alert evidence…")
2969
+ yield _emit(status_log, wx=wx_outputs, sat=sat_outputs, fc=fc_outputs)
2970
+ visual_evidence = _report_visual_evidence(wx_outputs, sat_outputs, fc_outputs, openai_api_key)
2971
+ react_log.append({"agent": "ReportingAgent", "thought": "Read actual plots and satellite scenes before drafting",
2972
+ "action": "inspect_chart_images",
2973
+ "observation": f"Inspected {len(visual_evidence['charts'])} charts. "
2974
+ + visual_evidence['observations'][:500]})
2975
+ alert_result = pplx_news_result
2976
+ if pplx_api_key and not pplx_news_result.get('citations'):
2977
+ try:
2978
+ alert_result = _call_perplexity(
2979
+ f"Find dated official active or recently issued severe weather alerts for {location} "
2980
+ f"from national meteorological services, civil protection or WMO-linked agencies. "
2981
+ "Give issuer, warning type, validity dates, exact source URLs, and distinguish "
2982
+ "confirmed warnings from press reporting. If unverified, explicitly say so.",
2983
+ pplx_api_key, recency='week')
2984
+ react_log.append({"agent": "ReportingAgent", "thought": "Initial alert lookup lacked citations",
2985
+ "action": "search_official_weather_alerts",
2986
+ "observation": f"Returned {len(alert_result.get('citations', []))} citations"})
2987
+ except Exception as exc:
2988
+ react_log.append({"agent": "ReportingAgent", "thought": "Supplementary search unavailable",
2989
+ "action": "continue_without_verified_alerts", "observation": str(exc)})
2990
+ status_log[-1] = "✅ [ReportingAgent] Visual and web evidence reviewed."
2991
+
2992
  synthesis_prompt = f"""
2993
  You are a senior climate-risk analyst. Produce a concise, evidence-grounded report for **{location}**.
2994
 
 
3007
  {historical_evidence}
3008
  Diagnostic stationarity note: {weather_data.get('stationarity_conclusion', 'N/A')}
3009
  Historical data error: {weather_data.get('error', 'none')}
3010
+ Historical weather anomaly signals (unverified statements from structured evidence): {json.dumps(weather_data.get('weather_anomalies', []), ensure_ascii=False)}
3011
+ Historical evidence alerts: {json.dumps(weather_data.get('weather_alerts', []), ensure_ascii=False)}
3012
+ - Distinguish meteorological anomalies from stationarity diagnostics. Do not invent anomaly thresholds or treat unverified signals as confirmed events.
3013
 
3014
  ## SATELLITE INDEX GLOSSARY — PLAIN LANGUAGE
3015
  - NDVI (Normalized Difference Vegetation Index): vegetation greenness/health; higher generally means denser, more active vegetation.
 
3057
  Use these rows exactly in the 7-Day Outlook; do not recalculate the differences.
3058
  {json.dumps(daily_outlook_comparison, ensure_ascii=False)}
3059
 
3060
+ ## ADDITIONAL REPORTING AGENT EVIDENCE
3061
+ Actual chart AND Sentinel-2 scene inspection (qualitative; do not invent numeric values):
3062
+ {visual_evidence['observations']}
3063
+ Charts inspected: {', '.join(visual_evidence['charts']) or 'none'}
3064
+ Official alert search and citations (unverified alerts must not be presented as confirmed):
3065
+ {json.dumps(alert_result, ensure_ascii=False)[:6500]}
3066
+
3067
  ## E) RECENT / UPCOMING EXTERNAL EVENT INTELLIGENCE
3068
  {str(pplx_news_result.get('content') or 'External event intelligence unavailable.')[:1600]}
3069
 
 
3202
  if missing_report.strip():
3203
  gpt_report = gpt_report.rstrip() + "\n\n---\n\n" + missing_report.strip()
3204
 
3205
+ react_log.append({"agent": "ReportingAgent", "thought": "Integrate numerical, visual and external evidence",
3206
+ "action": "draft_report", "observation": "Initial eight-section report drafted"})
3207
+ gpt_report = _report_react_review(
3208
+ gpt_report, synthesis_prompt, visual_evidence, alert_result, openai_api_key, react_log)
3209
+ status_log[-1] = "✅ [6/6] Reporting Agent ReAct review complete."
3210
  except Exception as exc:
3211
  status_log[-1] = f"⚠️ [6/6] GPT error: {exc}"
3212
  gpt_report = f"GPT synthesis failed: {exc}"
 
3239
  gr.Markdown(
3240
  "Orchestrates Historical, Sentinel-2 and Forecast agents, compares the statistical "
3241
  "forecast with **Open-Meteo as an operational reference forecast**, and queries "
3242
+ "**Perplexity** for recent/upcoming extreme-event intelligence. The Reporting Agent reviews the "
3243
  "already-structured evidence; it does not create numeric risk scores.\n\n"
3244
  "_The report is hazard-focused: exposure and vulnerability are not modelled. "
3245
  "OpenAI and Perplexity credentials are loaded server-side from Hugging Face Space Secrets._"
 
3304
  rp_forecast_summary = gr.State("")
3305
 
3306
  with gr.Tabs():
3307
+ with gr.Tab("📋 Hazard-Focused Report (ReAct Agent)"):
3308
  rp_gpt_report = gr.Markdown(label="Full Report")
3309
 
3310
  with gr.Tab("📡 Operational Reference (Open-Meteo)"):