FajarHidaa commited on
Commit
ca18fc5
·
verified ·
1 Parent(s): 719e708

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +2038 -0
app.py ADDED
@@ -0,0 +1,2038 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Space Weather - Weather Intelligence Application
3
+ Built with Gradio, LiteLLM, Open-Meteo, and Gemini
4
+ Compatible with Gradio 6.x
5
+ Browser-cache version: SINGLE ENTRY (overwrite old data)
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import re
11
+ import html
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+ from typing import Dict, List, Optional, Tuple, Any
15
+ from dataclasses import dataclass
16
+ import logging
17
+
18
+ import gradio as gr
19
+ import requests
20
+ from litellm import completion
21
+ from gemini_key_manager import GeminiKeyManager, classify_gemini_error
22
+
23
+ # Suppress Gradio 6.x internal warnings
24
+ import warnings
25
+ warnings.filterwarnings("ignore", category=RuntimeWarning, message="coroutine.*was never awaited")
26
+ warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*event loop.*")
27
+
28
+
29
+
30
+ # ------------------------------------------------------------------
31
+ # FILE LOADERS
32
+ # ------------------------------------------------------------------
33
+ def load_text(path: str) -> str:
34
+ try:
35
+ with open(path, "r", encoding="utf-8") as f:
36
+ return f.read()
37
+ except Exception as e:
38
+ logger.error(f"Failed to load {path}: {e}")
39
+ return ""
40
+
41
+ def load_json(path: str) -> dict:
42
+ try:
43
+ with open(path, "r", encoding="utf-8") as f:
44
+ return json.load(f)
45
+ except Exception as e:
46
+ logger.error(f"Failed to load {path}: {e}")
47
+ return {}
48
+
49
+
50
+
51
+
52
+ # ------------------------------------------------------------------
53
+ # CONFIGURATION
54
+ # ------------------------------------------------------------------
55
+ logging.basicConfig(level=logging.INFO)
56
+ logger = logging.getLogger(__name__)
57
+
58
+ OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
59
+
60
+ # Directory to write user-downloadable JSON export files
61
+ EXPORT_DIR = "/tmp/weather_exports"
62
+
63
+
64
+ # Definisikan link Shopee Affiliate di sini agar mudah diubah
65
+ SHOPEE_LINK = "https://shopee.co.id/"
66
+
67
+ # Gemini API key rotation manager (baca GEMINI_API_KEY_1..N dari secrets)
68
+ key_manager = GeminiKeyManager() # <-- BARU
69
+
70
+
71
+
72
+ # ------------------------------------------------------------------
73
+ # STATE MACHINE CONSTANTS
74
+ # ------------------------------------------------------------------
75
+ STATE_READY = "READY"
76
+ STATE_LOCATING = "LOCATING"
77
+ STATE_LOCATION_READY = "LOCATION_READY"
78
+ STATE_ANALYZING = "ANALYZING"
79
+ STATE_FETCHING_WEATHER = "FETCHING_WEATHER"
80
+ STATE_PROCESSING_DATA = "PROCESSING_DATA"
81
+ STATE_GENERATING_INSIGHT = "GENERATING_INSIGHT"
82
+ STATE_VALIDATING_OUTPUT = "VALIDATING_OUTPUT"
83
+ STATE_COMPLETED = "COMPLETED"
84
+ STATE_ERROR = "ERROR"
85
+ STATE_LOCKED_TODAY = "LOCKED_TODAY"
86
+
87
+ PROGRESS_STEPS = {
88
+ STATE_FETCHING_WEATHER: ("Step 1/5", "Getting weather data..."),
89
+ STATE_PROCESSING_DATA: ("Step 2/5", "Processing weather data..."),
90
+ STATE_GENERATING_INSIGHT: ("Step 3/5", "Generating weather insight..."),
91
+ STATE_VALIDATING_OUTPUT: ("Step 4/5", "Validating analysis..."),
92
+ STATE_COMPLETED: ("Step 5/5", "Analysis complete"),
93
+ }
94
+
95
+ STEP_ORDER = [
96
+ STATE_FETCHING_WEATHER,
97
+ STATE_PROCESSING_DATA,
98
+ STATE_GENERATING_INSIGHT,
99
+ STATE_VALIDATING_OUTPUT,
100
+ STATE_COMPLETED,
101
+ ]
102
+ STEP_LABELS = ["Weather Data", "Processing", "Insight", "Validation", "Complete"]
103
+
104
+ STATUS_VARIANTS = {
105
+ STATE_READY: ("neutral", "Ready"),
106
+ STATE_LOCATION_READY: ("info", "Location ready"),
107
+ STATE_ANALYZING: ("active", "Analyzing"),
108
+ STATE_ERROR: ("error", "Error"),
109
+ STATE_LOCKED_TODAY: ("info", "Locked"),
110
+ STATE_COMPLETED: ("success", "Complete"),
111
+ }
112
+
113
+ VALID_SEVERITY = {"low", "medium", "high"}
114
+ VALID_CONFIDENCE = {"low", "medium", "high"}
115
+ VALID_PRIORITY = {"low", "medium", "high"}
116
+
117
+ WEATHER_VARIABLES = [
118
+ "temperature_2m_max", "temperature_2m_min", "rain_sum", "precipitation_sum",
119
+ "wind_gusts_10m_max", "shortwave_radiation_sum", "temperature_2m_mean",
120
+ "cloud_cover_mean", "et0_fao_evapotranspiration",
121
+ "growing_degree_days_base_0_limit_50", "leaf_wetness_probability_mean",
122
+ "vapour_pressure_deficit_max"
123
+ ]
124
+
125
+
126
+ # ------------------------------------------------------------------
127
+ # DATA CLASSES
128
+ # ------------------------------------------------------------------
129
+ @dataclass
130
+ class AppState:
131
+ state: str = STATE_READY
132
+ is_analyzing: bool = False
133
+ location: Optional[Dict] = None
134
+ client_date: Optional[str] = None
135
+ client_timezone: Optional[str] = None
136
+ browser_date: Optional[str] = None
137
+ browser_timezone: Optional[str] = None
138
+ cache: Optional[Dict] = None # Now a SINGLE entry, not a dict of dates
139
+
140
+ def to_dict(self):
141
+ return {
142
+ "state": self.state,
143
+ "is_analyzing": self.is_analyzing,
144
+ "location": self.location,
145
+ "client_date": self.client_date,
146
+ "client_timezone": self.client_timezone,
147
+ "browser_date": self.browser_date,
148
+ "browser_timezone": self.browser_timezone,
149
+ "cache": self.cache,
150
+ }
151
+
152
+ @classmethod
153
+ def from_dict(cls, d):
154
+ return cls(
155
+ state=d.get("state", STATE_READY),
156
+ is_analyzing=d.get("is_analyzing", False),
157
+ location=d.get("location"),
158
+ client_date=d.get("client_date"),
159
+ client_timezone=d.get("client_timezone"),
160
+ browser_date=d.get("browser_date"),
161
+ browser_timezone=d.get("browser_timezone"),
162
+ cache=d.get("cache"),
163
+ )
164
+
165
+
166
+ # ------------------------------------------------------------------
167
+ # UTILITY FORMATTERS
168
+ # ------------------------------------------------------------------
169
+ def validate_coordinates(lat: Any, lon: Any) -> Tuple[bool, str]:
170
+ if lat is None or lon is None:
171
+ return False, "Invalid location. Please enter a valid latitude and longitude."
172
+ try:
173
+ lat_f = float(lat)
174
+ lon_f = float(lon)
175
+ except (ValueError, TypeError):
176
+ return False, "Invalid location. Please enter a valid latitude and longitude."
177
+
178
+ if not (-90 <= lat_f <= 90):
179
+ return False, "Invalid location. Latitude must be between -90 and 90."
180
+ if not (-180 <= lon_f <= 180):
181
+ return False, "Invalid location. Longitude must be between -180 and 180."
182
+
183
+ return True, ""
184
+
185
+
186
+ def validate_coordinates_ui(lat, lon, state_dict):
187
+ state = AppState.from_dict(state_dict)
188
+ if state.state == STATE_LOCKED_TODAY:
189
+ return gr.update(interactive=False)
190
+ if (lat is None or lat == 0) or (lon is None or lon == 0):
191
+ return gr.update(interactive=False)
192
+ return gr.update(interactive=True)
193
+
194
+
195
+
196
+ def esc(value: Any) -> str:
197
+ if value is None:
198
+ return ""
199
+ return html.escape(str(value))
200
+
201
+
202
+ def format_status(state: str, message: str = "") -> str:
203
+ if state in PROGRESS_STEPS and state not in STATUS_VARIANTS:
204
+ variant = "active"
205
+ label = PROGRESS_STEPS[state][1]
206
+ else:
207
+ variant, label = STATUS_VARIANTS.get(state, ("neutral", state.replace("_", " ").title()))
208
+
209
+ detail = f'<span class="status-detail">{esc(message)}</span>' if message else ""
210
+ return (
211
+ f'<div class="status-pill status-pill--{variant}">'
212
+ f'<span class="status-dot"></span>'
213
+ f'<span class="status-label">{esc(label)}</span>'
214
+ f'{detail}'
215
+ f'</div>'
216
+ )
217
+
218
+
219
+ def format_location_status(source: str, accuracy: Optional[float]) -> str:
220
+ source_label = {"gps": "GPS", "ip": "IP geolocation"}.get(source, source or "Manual")
221
+ accuracy_str = f" &plusmn;{accuracy:.0f}m" if accuracy else ""
222
+ return (
223
+ f'<div class="status-pill status-pill--info">'
224
+ f'<span class="status-dot"></span>'
225
+ f'<span class="status-label">Location ready</span>'
226
+ f'<span class="status-detail">{esc(source_label)}{accuracy_str}</span>'
227
+ f'</div>'
228
+ )
229
+
230
+
231
+ def render_step_tracker(state: str, error: bool = False) -> str:
232
+ if state not in STEP_ORDER:
233
+ return ""
234
+ idx = STEP_ORDER.index(state)
235
+
236
+ items = []
237
+ for i, label in enumerate(STEP_LABELS):
238
+ is_done = i < idx or (i == idx and state == STATE_COMPLETED)
239
+ if error and i == idx:
240
+ cls, marker = "is-error", "&times;"
241
+ elif is_done:
242
+ cls, marker = "is-done", "&check;"
243
+ elif i == idx:
244
+ cls, marker = "is-active", f"{i + 1:02d}"
245
+ else:
246
+ cls, marker = "is-pending", f"{i + 1:02d}"
247
+
248
+ items.append(
249
+ f'<div class="step {cls}">'
250
+ f'<span class="step-marker">{marker}</span>'
251
+ f'<span class="step-label">{esc(label)}</span>'
252
+ f'</div>'
253
+ )
254
+ if i < len(STEP_LABELS) - 1:
255
+ filled = "filled" if is_done else ""
256
+ items.append(f'<div class="step-connector {filled}"></div>')
257
+
258
+ return f'<div class="step-tracker">{"".join(items)}</div>'
259
+
260
+
261
+ def format_progress(state: str, error: bool = False) -> str:
262
+ return render_step_tracker(state, error=error)
263
+
264
+
265
+ def format_cache_info(cache_entry: Optional[Dict]) -> str:
266
+ if not cache_entry:
267
+ return ""
268
+
269
+ # --- Tanggal analisis (sudah browser_date dari handle_weather_and_analyze) ---
270
+ client_date = cache_entry.get("client_date", "Unknown")
271
+ if client_date != "Unknown":
272
+ try:
273
+ client_date = datetime.strptime(client_date, "%Y-%m-%d").strftime("%d %b %Y")
274
+ except Exception:
275
+ pass
276
+
277
+ # --- Waktu generated: konversi UTC → browser timezone ---
278
+ created = cache_entry.get("created_at", "Unknown")
279
+ browser_tz = cache_entry.get("browser_timezone") or cache_entry.get("client_timezone") or "UTC"
280
+
281
+ if created != "Unknown":
282
+ try:
283
+ dt = datetime.fromisoformat(created)
284
+ # Pastikan aware (UTC)
285
+ if dt.tzinfo is None:
286
+ dt = dt.replace(tzinfo=timezone.utc)
287
+
288
+ if browser_tz and browser_tz != "UTC":
289
+ try:
290
+ from zoneinfo import ZoneInfo
291
+ dt_local = dt.astimezone(ZoneInfo(browser_tz))
292
+ created = dt_local.strftime("%d %b %Y, %H:%M") + f" · {browser_tz}"
293
+ except Exception:
294
+ # Fallback kalau ZoneInfo tidak kenal timezone
295
+ created = dt.strftime("%d %b %Y, %H:%M UTC")
296
+ else:
297
+ created = dt.strftime("%d %b %Y, %H:%M UTC")
298
+ except Exception:
299
+ pass
300
+
301
+ location = cache_entry.get("location", {}) or {}
302
+ source = location.get("source", "manual")
303
+ source_label = {"gps": "GPS", "ip": "IP Geolocation", "manual": "Manual"}.get(source, source)
304
+
305
+ return (
306
+ f'<div class="cache-card">'
307
+ f'<div class="cache-card-icon">&check;</div>'
308
+ f'<div class="cache-card-body">'
309
+ f'<div class="cache-card-title">Today&rsquo;s analysis is ready</div>'
310
+ f'<div class="cache-card-meta">'
311
+ f'<span>{esc(client_date)}</span><span class="dot-sep">&middot;</span><span>{esc(source_label)}</span>'
312
+ f'</div>'
313
+ f'<div class="cache-card-meta cache-card-meta--faint">Generated {esc(created)}</div>'
314
+ f'</div>'
315
+ f'</div>'
316
+ )
317
+
318
+
319
+ def render_placeholder(title: str, message: str, variant: str = "neutral") -> str:
320
+ return (
321
+ f'<div class="placeholder-card placeholder-card--{variant}">'
322
+ f'<div class="placeholder-mark"></div>'
323
+ f'<div class="placeholder-title">{esc(title)}</div>'
324
+ f'<div class="placeholder-message">{esc(message)}</div>'
325
+ f'</div>'
326
+ )
327
+
328
+
329
+ def render_sponsors_html(sponsors: List[Dict]) -> str:
330
+ if not sponsors:
331
+ return '<div class="sponsor-empty">No sponsors listed.</div>'
332
+ items = []
333
+ for s in sponsors:
334
+ items.append(
335
+ f'<a class="sponsor-item" href="{esc(s.get("link", "#"))}" '
336
+ f'target="_blank" rel="noopener noreferrer" title="{esc(s.get("name", ""))}">'
337
+ f'<img class="sponsor-img" src="{esc(s.get("image", ""))}" '
338
+ f'alt="{esc(s.get("name", ""))}" loading="lazy">'
339
+ f'</a>'
340
+ )
341
+ return f'<div class="sponsor-grid">{"".join(items)}</div>'
342
+
343
+
344
+
345
+ def severity_badge(sev: Optional[str]) -> str:
346
+ sev_l = (sev or "unknown").lower()
347
+ cls = {"low": "badge--good", "medium": "badge--warn", "high": "badge--bad"}.get(sev_l, "badge--neutral")
348
+ return f'<span class="badge {cls}">{esc(sev_l.upper())}</span>'
349
+
350
+
351
+ def priority_badge(pri: Optional[str]) -> str:
352
+ pri_l = (pri or "unknown").lower()
353
+ cls = {"low": "badge--good", "medium": "badge--warn", "high": "badge--bad"}.get(pri_l, "badge--neutral")
354
+ return f'<span class="badge {cls}">{esc(pri_l.upper())} PRI.</span>'
355
+
356
+
357
+ def confidence_badge(conf: Optional[str]) -> str:
358
+ conf_l = (conf or "unknown").lower()
359
+ cls = {"high": "badge--good", "medium": "badge--warn", "low": "badge--neutral"}.get(conf_l, "badge--neutral")
360
+ return f'<span class="badge {cls} badge--outline">{esc(conf_l.upper())} CONF.</span>'
361
+
362
+
363
+ def render_summary_tab(analysis: Dict) -> str:
364
+ if not analysis:
365
+ return render_placeholder("No analysis yet", "Enter coordinates and select Analyze to generate today's insight.")
366
+ parts = ['<div class="insight-card">']
367
+ parts.append(f'<p class="insight-summary">{esc(analysis.get("summary", "No summary available."))}</p>')
368
+ overall = analysis.get("overall_confidence", "")
369
+ if overall:
370
+ parts.append(f'<div class="insight-footer">Overall confidence&nbsp;{confidence_badge(overall)}</div>')
371
+ parts.append('</div>')
372
+ return "".join(parts)
373
+
374
+ def render_historical_tab(analysis: Dict) -> str:
375
+ if not analysis:
376
+ return render_placeholder("No data yet", "Run analysis to view historical records.")
377
+ hist = analysis.get("historical", {}) or {}
378
+ parts = ['<div class="insight-card">']
379
+ parts.append('<div class="insight-section">')
380
+ parts.append('<div class="section-eyebrow">Historical &middot; Last 7 Days</div>')
381
+ # New format: condition + impact
382
+ if hist.get("condition"):
383
+ parts.append(f'<p class="section-text"><strong>Kondisi:</strong> {esc(hist["condition"])}</p>')
384
+ if hist.get("impact"):
385
+ parts.append(f'<p class="section-text"><strong>Dampak:</strong> {esc(hist["impact"])}</p>')
386
+ # Old format: summary + key_conditions (backward-compatible)
387
+ if hist.get("summary"):
388
+ parts.append(f'<p class="section-text">{esc(hist["summary"])}</p>')
389
+ conds = hist.get("key_conditions") or []
390
+ if conds:
391
+ parts.append('<ul class="condition-list">' + "".join(f'<li>{esc(c)}</li>' for c in conds) + '</ul>')
392
+ # Fallback if nothing found
393
+ if not hist.get("condition") and not hist.get("impact") and not hist.get("summary") and not conds:
394
+ parts.append('<p class="section-text">No historical data recorded.</p>')
395
+ parts.append('</div></div>')
396
+ return "".join(parts)
397
+
398
+
399
+
400
+ def render_forecast_tab(analysis: Dict) -> str:
401
+ if not analysis:
402
+ return render_placeholder("No data yet", "Run analysis to view forecast records.")
403
+ fcst = analysis.get("forecast", {}) or {}
404
+ parts = ['<div class="insight-card">']
405
+ parts.append('<div class="insight-section">')
406
+ parts.append('<div class="section-eyebrow">Forecast &middot; Next 7 Days</div>')
407
+ # New format: condition + impact
408
+ if fcst.get("condition"):
409
+ parts.append(f'<p class="section-text"><strong>Kondisi:</strong> {esc(fcst["condition"])}</p>')
410
+ if fcst.get("impact"):
411
+ parts.append(f'<p class="section-text"><strong>Dampak:</strong> {esc(fcst["impact"])}</p>')
412
+ # Old format: summary + key_conditions (backward-compatible)
413
+ if fcst.get("summary"):
414
+ parts.append(f'<p class="section-text">{esc(fcst["summary"])}</p>')
415
+ conds = fcst.get("key_conditions") or []
416
+ if conds:
417
+ parts.append('<ul class="condition-list">' + "".join(f'<li>{esc(c)}</li>' for c in conds) + '</ul>')
418
+ # Fallback if nothing found
419
+ if not fcst.get("condition") and not fcst.get("impact") and not fcst.get("summary") and not conds:
420
+ parts.append('<p class="section-text">No forecast data recorded.</p>')
421
+ parts.append('</div></div>')
422
+ return "".join(parts)
423
+
424
+
425
+ def render_risks_tab(analysis: Dict) -> str:
426
+ if not analysis:
427
+ return render_placeholder("No data yet", "Run analysis to view risk signals.")
428
+ risks = analysis.get("risks") or []
429
+ parts = ['<div class="insight-card">']
430
+ parts.append('<div class="insight-section">')
431
+ parts.append('<div class="section-eyebrow">Risk Signals</div>')
432
+ if risks:
433
+ parts.append('<div class="risk-list">')
434
+ for risk in risks:
435
+ # Backward-compatible: old data uses "type", new data uses "title"
436
+ title = str(risk.get("title") or risk.get("type", "Unknown Risk"))
437
+ # Old data uses "confidence", new data doesn't have it — just show severity
438
+ badges = severity_badge(risk.get("severity"))
439
+ parts.append('<div class="risk-item">')
440
+ parts.append(
441
+ f'<div class="risk-item-head">'
442
+ f'<span class="risk-item-title">{esc(title)}</span>'
443
+ f'<span class="risk-item-badges">{badges}</span>'
444
+ f'</div>'
445
+ )
446
+ # New format has "description", old format doesn't
447
+ if risk.get("description"):
448
+ parts.append(f'<p class="section-text" style="margin:6px 0 4px;font-size:13px;">{esc(risk["description"])}</p>')
449
+ if risk.get("evidence"):
450
+ parts.append(f'<p class="risk-item-evidence">{esc(risk["evidence"])}</p>')
451
+ # New format has "period", old format doesn't
452
+ if risk.get("period"):
453
+ parts.append(f'<p class="risk-item-evidence" style="color:var(--info);margin-top:4px;">&#128197; {esc(risk["period"])}</p>')
454
+ parts.append('</div>')
455
+ parts.append('</div>')
456
+ else:
457
+ parts.append('<p class="section-text">No significant weather risks detected.</p>')
458
+ parts.append('</div></div>')
459
+ return "".join(parts)
460
+
461
+
462
+ def render_recommendations_tab(analysis: Dict) -> str:
463
+ if not analysis:
464
+ return render_placeholder("No data yet", "Run analysis to view recommendations.")
465
+ parts = ['<div class="insight-card">']
466
+ parts.append('<div class="insight-section insight-section--recommendation" style="margin-bottom:0;">')
467
+ parts.append('<div class="section-eyebrow">Recommendation</div>')
468
+ recs = analysis.get("recommendations") or []
469
+ if recs:
470
+ parts.append('<div class="risk-list">')
471
+ for rec in recs:
472
+ # Backward-compatible: handle old string format AND new object format
473
+ if isinstance(rec, str):
474
+ # Old format: just a string
475
+ action = rec
476
+ reason = ""
477
+ priority = ""
478
+ else:
479
+ # New format: object with action, reason, priority
480
+ action = str(rec.get("action", ""))
481
+ reason = str(rec.get("reason", ""))
482
+ priority = rec.get("priority", "")
483
+ parts.append('<div class="risk-item" style="border-color:rgba(232,163,61,0.25);">')
484
+ parts.append(
485
+ f'<div class="risk-item-head">'
486
+ f'<span class="risk-item-title">{esc(action)}</span>'
487
+ f'<span class="risk-item-badges">{priority_badge(priority)}</span>'
488
+ f'</div>'
489
+ )
490
+ if reason:
491
+ parts.append(f'<p class="risk-item-evidence">{esc(reason)}</p>')
492
+ parts.append('</div>')
493
+ parts.append('</div>')
494
+ else:
495
+ parts.append('<p class="section-text">No immediate action indicated.</p>')
496
+ parts.append('</div></div>')
497
+ return "".join(parts)
498
+
499
+
500
+ # ------------------------------------------------------------------
501
+ # 3. WEATHER SERVICE
502
+ # ------------------------------------------------------------------
503
+ def fetch_weather_data(lat: float, lon: float) -> Tuple[Optional[Dict], str]:
504
+ variables_str = ",".join(WEATHER_VARIABLES)
505
+ url = (
506
+ f"{OPEN_METEO_URL}?latitude={lat}&longitude={lon}"
507
+ f"&daily={variables_str}"
508
+ f"&timezone=auto&past_days=7&forecast_days=7"
509
+ )
510
+
511
+ try:
512
+ resp = requests.get(url, timeout=30)
513
+ if resp.status_code != 200:
514
+ return None, f"Weather data unavailable. Please try again later. (HTTP {resp.status_code})"
515
+
516
+ data = resp.json()
517
+ if not isinstance(data, dict):
518
+ return None, "Weather data unavailable. Please try again later. (Invalid JSON)"
519
+
520
+ if "daily" not in data:
521
+ return None, "Weather data unavailable. Please try again later. (Missing daily data)"
522
+
523
+ daily = data["daily"]
524
+ required_vars = ["time"] + WEATHER_VARIABLES
525
+ for var in required_vars:
526
+ if var not in daily:
527
+ return None, f"Weather data unavailable. Please try again later. (Missing variable: {var})"
528
+
529
+ dates = daily["time"]
530
+ if not isinstance(dates, list) or len(dates) == 0:
531
+ return None, "Weather data unavailable. Please try again later. (Invalid date array)"
532
+
533
+ if len(dates) < 8:
534
+ return None, f"Weather data unavailable. Please try again later. (Insufficient data: {len(dates)} days)"
535
+
536
+ return data, ""
537
+
538
+ except requests.Timeout:
539
+ return None, "Weather data unavailable. Request timed out. Please try again later."
540
+ except Exception as e:
541
+ logger.error(f"Weather fetch error: {e}")
542
+ return None, "Weather data unavailable. Please try again later."
543
+
544
+
545
+
546
+
547
+
548
+
549
+
550
+
551
+
552
+ def handle_weather_and_analyze(weather_json_str: str, lat: float, lon: float, crop: str, phenology: str, notes: str, current_concern: str, state_dict: Dict):
553
+ state = AppState.from_dict(state_dict)
554
+ cache_entry = state.cache
555
+
556
+ if not weather_json_str:
557
+ yield (
558
+ state.to_dict(),
559
+ gr.update(interactive=True), gr.update(interactive=True),
560
+ format_status(STATE_READY),
561
+ format_progress(STATE_READY),
562
+ format_cache_info(cache_entry),
563
+ gr.update(visible=True),
564
+ gr.update(visible=False),
565
+ gr.update(visible=False),
566
+ render_summary_tab({}),
567
+ render_historical_tab({}),
568
+ render_forecast_tab({}),
569
+ render_risks_tab({}),
570
+ render_recommendations_tab({}),
571
+ json.dumps(cache_entry) if cache_entry else ""
572
+ )
573
+ return
574
+
575
+ try:
576
+ raw_weather = json.loads(weather_json_str)
577
+ except Exception as exc:
578
+ err_msg = f"Failed to parse weather data: {exc}"
579
+ yield (
580
+ state.to_dict(),
581
+ gr.update(interactive=True), gr.update(interactive=True),
582
+ format_status(STATE_ERROR, err_msg),
583
+ format_progress(STATE_FETCHING_WEATHER, error=True), "",
584
+ gr.update(visible=True),
585
+ gr.update(visible=False),
586
+ gr.update(visible=False),
587
+ render_placeholder("Analysis failed", err_msg, "error"),
588
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
589
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
590
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
591
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
592
+ gr.update(value="")
593
+ )
594
+ return
595
+
596
+ if "error" in raw_weather:
597
+ error_msg = raw_weather["error"]
598
+ yield (
599
+ state.to_dict(), gr.update(interactive=True), gr.update(interactive=True),
600
+ format_status(STATE_ERROR, error_msg),
601
+ format_progress(STATE_FETCHING_WEATHER, error=True), "",
602
+ gr.update(visible=True),
603
+ gr.update(visible=False),
604
+ gr.update(visible=False),
605
+ render_placeholder("Analysis failed", error_msg, "error"),
606
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
607
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
608
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
609
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
610
+ gr.update(value="")
611
+ )
612
+ return
613
+
614
+ # <-- UBAH: gunakan tanggal browser, fallback ke server
615
+ client_date = state.browser_date or datetime.now().strftime("%Y-%m-%d")
616
+ state.client_date = client_date
617
+
618
+
619
+ if "error" in raw_weather:
620
+ error_msg = raw_weather["error"]
621
+ yield (
622
+ state.to_dict(), gr.update(interactive=True), gr.update(interactive=True),
623
+ format_status(STATE_ERROR, error_msg),
624
+ format_progress(STATE_FETCHING_WEATHER, error=True), "",
625
+ gr.update(visible=True),
626
+ gr.update(visible=False),
627
+ gr.update(visible=False),
628
+ render_placeholder("Analysis failed", error_msg, "error"),
629
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
630
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
631
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
632
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
633
+ gr.update(value="")
634
+ )
635
+ return
636
+
637
+ now = datetime.now()
638
+
639
+ state.state = STATE_PROCESSING_DATA
640
+ yield (
641
+ state.to_dict(),
642
+ gr.update(interactive=False), gr.update(interactive=False),
643
+ format_status(STATE_PROCESSING_DATA),
644
+ format_progress(STATE_PROCESSING_DATA),
645
+ "",
646
+ gr.update(visible=True),
647
+ gr.update(visible=False),
648
+ gr.update(visible=False),
649
+ render_placeholder("Processing weather data", "Splitting historical and forecast windows and computing derived indices.", "active"),
650
+ render_placeholder("No data yet", "Waiting for processing...", "neutral"),
651
+ render_placeholder("No data yet", "Waiting for processing...", "neutral"),
652
+ render_placeholder("No data yet", "Waiting for processing...", "neutral"),
653
+ render_placeholder("No data yet", "Waiting for processing...", "neutral"),
654
+ gr.update(value="")
655
+ )
656
+
657
+ normalized, norm_error = normalize_weather_data(raw_weather, lat, lon, client_date)
658
+ if norm_error:
659
+ failed_step = state.state
660
+ state.is_analyzing = False
661
+ state.state = STATE_ERROR
662
+ yield (
663
+ state.to_dict(),
664
+ gr.update(interactive=True), gr.update(interactive=True),
665
+ format_status(STATE_ERROR, norm_error),
666
+ format_progress(failed_step, error=True), "",
667
+ gr.update(visible=True),
668
+ gr.update(visible=False),
669
+ gr.update(visible=False),
670
+ render_placeholder("Analysis failed", norm_error, "error"),
671
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
672
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
673
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
674
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
675
+ gr.update(value="")
676
+ )
677
+ return
678
+
679
+ state.state = STATE_GENERATING_INSIGHT
680
+ yield (
681
+ state.to_dict(),
682
+ gr.update(interactive=False), gr.update(interactive=False),
683
+ format_status(STATE_GENERATING_INSIGHT),
684
+ format_progress(STATE_GENERATING_INSIGHT),
685
+ "",
686
+ gr.update(visible=True),
687
+ gr.update(visible=False),
688
+ gr.update(visible=False),
689
+ render_placeholder("Generating weather insight", "Gemini is interpreting the data for risks and recommendations.", "active"),
690
+ render_placeholder("No data yet", "Waiting for insight...", "neutral"),
691
+ render_placeholder("No data yet", "Waiting for insight...", "neutral"),
692
+ render_placeholder("No data yet", "Waiting for insight...", "neutral"),
693
+ render_placeholder("No data yet", "Waiting for insight...", "neutral"),
694
+ gr.update(value="")
695
+ )
696
+
697
+ payload = build_llm_payload(normalized, lat, lon, client_date, crop, phenology, notes, current_concern)
698
+ llm_output, gemini_error = call_gemini(payload)
699
+
700
+ if gemini_error:
701
+ failed_step = state.state
702
+ state.is_analyzing = False
703
+ state.state = STATE_ERROR
704
+ yield (
705
+ state.to_dict(),
706
+ gr.update(interactive=True), gr.update(interactive=True),
707
+ format_status(STATE_ERROR, gemini_error),
708
+ format_progress(failed_step, error=True), "",
709
+ gr.update(visible=True),
710
+ gr.update(visible=False),
711
+ gr.update(visible=False),
712
+ render_placeholder("Analysis failed", gemini_error, "error"),
713
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
714
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
715
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
716
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
717
+ gr.update(value="")
718
+ )
719
+ return
720
+
721
+ state.state = STATE_VALIDATING_OUTPUT
722
+ yield (
723
+ state.to_dict(),
724
+ gr.update(interactive=False), gr.update(interactive=False),
725
+ format_status(STATE_VALIDATING_OUTPUT),
726
+ format_progress(STATE_VALIDATING_OUTPUT),
727
+ "",
728
+ gr.update(visible=True),
729
+ gr.update(visible=False),
730
+ gr.update(visible=False),
731
+ render_placeholder("Validating analysis", "Checking the response against the expected schema.", "active"),
732
+ render_placeholder("No data yet", "Waiting for validation...", "neutral"),
733
+ render_placeholder("No data yet", "Waiting for validation...", "neutral"),
734
+ render_placeholder("No data yet", "Waiting for validation...", "neutral"),
735
+ render_placeholder("No data yet", "Waiting for validation...", "neutral"),
736
+ gr.update(value="")
737
+ )
738
+
739
+ valid, val_error = validate_llm_output(llm_output)
740
+ if not valid:
741
+ failed_step = state.state
742
+ state.is_analyzing = False
743
+ state.state = STATE_ERROR
744
+ yield (
745
+ state.to_dict(),
746
+ gr.update(interactive=True), gr.update(interactive=True),
747
+ format_status(STATE_ERROR, val_error),
748
+ format_progress(failed_step, error=True), "",
749
+ gr.update(visible=True),
750
+ gr.update(visible=False),
751
+ gr.update(visible=False),
752
+ render_placeholder("Analysis failed", val_error, "error"),
753
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
754
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
755
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
756
+ render_placeholder("No data yet", "Analysis interrupted.", "neutral"),
757
+ gr.update(value="")
758
+ )
759
+ return
760
+
761
+ state.state = STATE_COMPLETED
762
+ timezone_str = raw_weather.get("timezone", "UTC")
763
+ location_date = normalized.get("meta", {}).get("location_date", client_date)
764
+
765
+ cache_entry = {
766
+ "location": state.location,
767
+ "client_date": client_date,
768
+ "location_date": location_date,
769
+ "location_timezone": timezone_str,
770
+ "client_timezone": state.client_timezone or "UTC",
771
+ "field_context": {
772
+ "crop_type": crop,
773
+ "phenology_phase": phenology,
774
+ "field_notes": notes,
775
+ "current_concern": current_concern,
776
+ },
777
+ "raw_weather": raw_weather,
778
+ "analysis": llm_output,
779
+ "created_at": datetime.now(timezone.utc).isoformat()
780
+ }
781
+
782
+ state.cache = cache_entry
783
+ state.is_analyzing = False
784
+
785
+ yield (
786
+ state.to_dict(),
787
+ gr.update(interactive=False), gr.update(interactive=False),
788
+ format_status(STATE_COMPLETED),
789
+ format_progress(STATE_COMPLETED),
790
+ format_cache_info(cache_entry),
791
+ gr.update(visible=False), # initial_placeholder hidden
792
+ gr.update(visible=True), # view_insight_btn visible
793
+ gr.update(visible=False), # tabs_container hidden until clicked
794
+ render_summary_tab(llm_output),
795
+ render_historical_tab(llm_output),
796
+ render_forecast_tab(llm_output),
797
+ render_risks_tab(llm_output),
798
+ render_recommendations_tab(llm_output),
799
+ json.dumps(cache_entry)
800
+ )
801
+
802
+ state.state = STATE_LOCKED_TODAY
803
+ yield (
804
+ state.to_dict(),
805
+ gr.update(interactive=False), gr.update(interactive=False),
806
+ format_status(STATE_LOCKED_TODAY, "Today's analysis complete. Return tomorrow for a new analysis."),
807
+ "",
808
+ format_cache_info(cache_entry),
809
+ gr.update(visible=False), # initial_placeholder hidden
810
+ gr.update(visible=True), # view_insight_btn visible
811
+ gr.update(visible=False), # tabs_container hidden until clicked
812
+ render_summary_tab(llm_output),
813
+ render_historical_tab(llm_output),
814
+ render_forecast_tab(llm_output),
815
+ render_risks_tab(llm_output),
816
+ render_recommendations_tab(llm_output),
817
+ json.dumps(cache_entry)
818
+ )
819
+
820
+
821
+
822
+
823
+
824
+ # ------------------------------------------------------------------
825
+ # 4. ANALYTICS ENGINE
826
+ # ------------------------------------------------------------------
827
+ def normalize_weather_data(raw_data: Dict, lat: float, lon: float, client_date: str) -> Tuple[Optional[Dict], str]:
828
+ try:
829
+ timezone_str = raw_data.get("timezone", "UTC")
830
+ dates = raw_data["daily"]["time"]
831
+ location_date = dates[7] if len(dates) > 7 else client_date
832
+
833
+ daily = raw_data["daily"]
834
+
835
+ historical_dates = dates[:7]
836
+ today_date = [dates[7]] if len(dates) > 7 else []
837
+ forecast_dates = dates[8:] if len(dates) > 8 else []
838
+
839
+ def extract_block(date_list):
840
+ if not date_list:
841
+ return {}
842
+ result = {"time": date_list}
843
+ for var in WEATHER_VARIABLES:
844
+ values = daily.get(var, [])
845
+ idx_start = dates.index(date_list[0])
846
+ idx_end = idx_start + len(date_list)
847
+ result[var] = values[idx_start:idx_end]
848
+ return result
849
+
850
+ historical = extract_block(historical_dates)
851
+ today = extract_block(today_date)
852
+ forecast = extract_block(forecast_dates)
853
+
854
+ units = raw_data.get("daily_units", {})
855
+ units_filtered = {k: v for k, v in units.items() if k != "time"}
856
+
857
+ normalized = {
858
+ "meta": {
859
+ "location": {
860
+ "latitude": raw_data.get("latitude", lat),
861
+ "longitude": raw_data.get("longitude", lon),
862
+ "timezone": timezone_str,
863
+ "elevation_m": raw_data.get("elevation")
864
+ },
865
+ "client_date": client_date,
866
+ "location_date": location_date,
867
+ "analysis_period": {
868
+ "historical": f"{len(historical_dates)} days",
869
+ "forecast": f"{len(forecast_dates)} days",
870
+ "anchor_date": client_date
871
+ }
872
+ },
873
+ "units": units_filtered,
874
+ "historical": historical,
875
+ "today": today,
876
+ "forecast": forecast
877
+ }
878
+ return normalized, ""
879
+
880
+ except Exception as e:
881
+ logger.error(f"Normalization error: {e}")
882
+ return None, "Failed to process weather data. Please try again later."
883
+
884
+
885
+ # ------------------------------------------------------------------
886
+ # 5. LLM ENGINE
887
+ # ------------------------------------------------------------------
888
+ def build_llm_payload(normalized_data: Dict, lat: float, lon: float, client_date: str, crop: str = "", phenology: str = "", notes: str = "", current_concern: str = "") -> Dict:
889
+ meta = normalized_data.get("meta", {})
890
+ location = meta.get("location", {})
891
+ return {
892
+ "location": {
893
+ "latitude": lat,
894
+ "longitude": lon,
895
+ "timezone": location.get("timezone", "UTC")
896
+ },
897
+ "field_context": {
898
+ "crop_type": crop or "Tidak ditentukan",
899
+ "phenology_phase": phenology or "Tidak ditentukan",
900
+ "field_notes": notes or "Tidak ada catatan khusus",
901
+ "current_concern": current_concern or "Tidak ada concern khusus"
902
+ },
903
+ "analysis_period": {
904
+ "historical": meta.get("analysis_period", {}).get("historical", "7 days"),
905
+ "forecast": meta.get("analysis_period", {}).get("forecast", "7 days"),
906
+ "anchor_date": client_date,
907
+ "anchor_type": "client_time",
908
+ "location_date": meta.get("location_date", client_date),
909
+ "location_timezone": location.get("timezone", "UTC")
910
+ },
911
+ "units": normalized_data.get("units", {}),
912
+ "historical": {"daily": normalized_data.get("historical", {})},
913
+ "forecast": {"daily": normalized_data.get("forecast", {})}
914
+ }
915
+
916
+
917
+ def call_gemini(payload: Dict) -> Tuple[Optional[Dict], str]:
918
+ system_prompt = load_text("prompts/system_prompt.txt")
919
+ if not system_prompt:
920
+ return None, "System prompt file is missing or empty. Please check prompts/system_prompt.txt"
921
+
922
+ user_prompt = f"Weather data for analysis:\n\n{json.dumps(payload, indent=2)}"
923
+ messages = [
924
+ {"role": "system", "content": system_prompt},
925
+ {"role": "user", "content": user_prompt},
926
+ ]
927
+
928
+ candidate_keys = key_manager.get_rotation_order()
929
+ last_error_msg = "Unknown error"
930
+
931
+ for key in candidate_keys:
932
+ if not key_manager._is_available(key):
933
+ continue
934
+
935
+ try:
936
+ response = completion(
937
+ model="gemini/gemini-3.5-flash",
938
+ messages=messages,
939
+ api_key=key,
940
+ temperature=1.0,
941
+ max_tokens=4000,
942
+ timeout=30,
943
+ )
944
+ content = response.choices[0].message.content
945
+
946
+ json_match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content)
947
+ if json_match:
948
+ content = json_match.group(1)
949
+ content = content.strip()
950
+ start_idx = content.find("{")
951
+ end_idx = content.rfind("}")
952
+ if start_idx != -1 and end_idx != -1:
953
+ content = content[start_idx:end_idx + 1]
954
+
955
+ result = json.loads(content)
956
+ return result, ""
957
+
958
+ except json.JSONDecodeError as e:
959
+ logger.error(f"JSON decode error: {e}")
960
+ return None, "Analysis could not be validated. Please try again. (Invalid JSON)"
961
+
962
+ except Exception as e:
963
+ kind = classify_gemini_error(e)
964
+ logger.warning(f"Key ...{key[-6:]} gagal ({kind}): {e}")
965
+
966
+ if kind == "rpd":
967
+ key_manager.mark_rpd_exhausted(key)
968
+ last_error_msg = "Satu atau lebih API key mencapai limit harian."
969
+ continue
970
+ elif kind == "rpm":
971
+ last_error_msg = "Key sedang sibuk (limit per menit), mencoba key lain."
972
+ continue
973
+ else:
974
+ last_error_msg = str(e)
975
+ continue
976
+
977
+ return None, (
978
+ "Weather data was retrieved, but the analysis could not be generated. "
979
+ f"Semua API key gagal atau kena limit. ({last_error_msg})"
980
+ )
981
+
982
+
983
+
984
+ # ------------------------------------------------------------------
985
+ # 6. OUTPUT + CACHE
986
+ # ------------------------------------------------------------------
987
+ def build_export_file(cache_entry: Optional[Dict]) -> Optional[str]:
988
+ """
989
+ Rangkai satu file TXT (plain text, mudah dibaca manusia maupun LLM)
990
+ berisi: input pengguna (lokasi & konteks lahan), data cuaca mentah
991
+ (historical + forecast dari Open-Meteo), dan hasil analisis Gemini.
992
+ File ini dimaksudkan agar pengguna bisa memakainya sebagai context
993
+ input di LLM lain untuk analisis lanjutan.
994
+ """
995
+ if not cache_entry:
996
+ return None
997
+
998
+ location = cache_entry.get("location", {}) or {}
999
+ field_context = cache_entry.get("field_context", {}) or {}
1000
+ analysis = cache_entry.get("analysis", {}) or {}
1001
+
1002
+ lines: List[str] = []
1003
+ lines.append("=== EXPORT DATA ANALISIS CUACA ===")
1004
+ lines.append(f"Dibuat pada : {datetime.now(timezone.utc).isoformat()}")
1005
+ lines.append("Tujuan : Context data untuk analisis lanjutan")
1006
+ lines.append("Sumber App : DigiTanist/tanam")
1007
+ lines.append("")
1008
+ lines.append("--- INPUT PENGGUNA ---")
1009
+ lines.append(f"Latitude : {location.get('latitude')}")
1010
+ lines.append(f"Longitude : {location.get('longitude')}")
1011
+ lines.append(f"Sumber lokasi : {location.get('source') or '-'}")
1012
+ if location.get("accuracy_m"):
1013
+ lines.append(f"Akurasi GPS : {location.get('accuracy_m')} m")
1014
+ lines.append(f"Tanggal analisis : {cache_entry.get('client_date')}")
1015
+ lines.append(f"Timezone lokasi : {cache_entry.get('location_timezone')}")
1016
+ lines.append("")
1017
+ lines.append("Konteks Lahan:")
1018
+ lines.append(f"- Jenis Tanaman : {field_context.get('crop_type') or '-'}")
1019
+ lines.append(f"- Fase Fenologi : {field_context.get('phenology_phase') or '-'}")
1020
+ lines.append(f"- Catatan Lapangan : {field_context.get('field_notes') or '-'}")
1021
+ lines.append(f"- Current Concern : {field_context.get('current_concern') or '-'}")
1022
+ lines.append("")
1023
+ lines.append("--- DATA CUACA MENTAH (Ecwf, historical 7 hari + forecast 7 hari) ---")
1024
+ lines.append(json.dumps(cache_entry.get("raw_weather", {}), ensure_ascii=False, indent=2))
1025
+ lines.append("")
1026
+ lines.append("--- HASIL ANALISIS GEMINI AI ---")
1027
+ lines.append(f"Ringkasan: {analysis.get('summary', '-')}")
1028
+ lines.append("")
1029
+
1030
+ hist = analysis.get("historical", {}) or {}
1031
+ lines.append("Historical (7 hari terakhir):")
1032
+ lines.append(f" Kondisi : {hist.get('condition', '-')}")
1033
+ lines.append(f" Dampak : {hist.get('impact', '-')}")
1034
+ lines.append("")
1035
+
1036
+ fcst = analysis.get("forecast", {}) or {}
1037
+ lines.append("Forecast (7 hari ke depan):")
1038
+ lines.append(f" Kondisi : {fcst.get('condition', '-')}")
1039
+ lines.append(f" Dampak : {fcst.get('impact', '-')}")
1040
+ lines.append("")
1041
+
1042
+ risks = analysis.get("risks", []) or []
1043
+ lines.append(f"Risiko ({len(risks)}):")
1044
+ if risks:
1045
+ for i, r in enumerate(risks, 1):
1046
+ lines.append(f" {i}. {r.get('title', '-')} [severity: {r.get('severity', '-')}]")
1047
+ lines.append(f" Deskripsi : {r.get('description', '-')}")
1048
+ lines.append(f" Bukti : {r.get('evidence', '-')}")
1049
+ lines.append(f" Periode : {r.get('period', '-')}")
1050
+ else:
1051
+ lines.append(" Tidak ada risiko signifikan.")
1052
+ lines.append("")
1053
+
1054
+ recs = analysis.get("recommendations", []) or []
1055
+ lines.append(f"Rekomendasi ({len(recs)}):")
1056
+ if recs:
1057
+ for i, r in enumerate(recs, 1):
1058
+ lines.append(f" {i}. {r.get('action', '-')} [priority: {r.get('priority', '-')}]")
1059
+ lines.append(f" Alasan : {r.get('reason', '-')}")
1060
+ else:
1061
+ lines.append(" Tidak ada tindakan khusus diperlukan.")
1062
+ lines.append("")
1063
+ lines.append(f"Overall Confidence: {analysis.get('overall_confidence', '-')}")
1064
+
1065
+ content = "\n".join(lines)
1066
+
1067
+ try:
1068
+ os.makedirs(EXPORT_DIR, exist_ok=True)
1069
+ date_tag = (cache_entry.get("client_date") or "unknown").replace("-", "")
1070
+ unique_id = uuid.uuid4().hex[:8] # cegah tabrakan nama file antar-user/klik
1071
+ path = os.path.join(EXPORT_DIR, f"weather_analysis_{date_tag}_{unique_id}.txt")
1072
+ with open(path, "w", encoding="utf-8") as f:
1073
+ f.write(content)
1074
+ return path
1075
+ except Exception as e:
1076
+ logger.error(f"Failed to build export file: {e}")
1077
+ return None
1078
+
1079
+
1080
+ def validate_llm_output(output: Dict) -> Tuple[bool, str]:
1081
+ required_fields = ["summary", "historical", "forecast", "risks", "recommendations", "overall_confidence"]
1082
+ for field in required_fields:
1083
+ if field not in output:
1084
+ return False, f"Analysis could not be validated. Missing field: {field}"
1085
+
1086
+ # Validate historical structure
1087
+ hist = output.get("historical", {})
1088
+ if not isinstance(hist, dict):
1089
+ return False, "Analysis could not be validated. Invalid historical format."
1090
+ if "condition" not in hist or "impact" not in hist:
1091
+ return False, "Analysis could not be validated. Historical missing condition or impact."
1092
+
1093
+ # Validate forecast structure
1094
+ fcst = output.get("forecast", {})
1095
+ if not isinstance(fcst, dict):
1096
+ return False, "Analysis could not be validated. Invalid forecast format."
1097
+ if "condition" not in fcst or "impact" not in fcst:
1098
+ return False, "Analysis could not be validated. Forecast missing condition or impact."
1099
+
1100
+ # Validate risks
1101
+ if not isinstance(output["risks"], list):
1102
+ return False, "Analysis could not be validated. Invalid risks format."
1103
+
1104
+ for i, risk in enumerate(output["risks"]):
1105
+ if not isinstance(risk, dict):
1106
+ return False, f"Analysis could not be validated. Invalid risk at index {i}."
1107
+ risk_required = ["title", "description", "evidence", "period", "severity"]
1108
+ for rf in risk_required:
1109
+ if rf not in risk:
1110
+ return False, f"Analysis could not be validated. Risk {i} missing field: {rf}"
1111
+ if risk.get("severity") not in VALID_SEVERITY:
1112
+ return False, f"Analysis could not be validated. Invalid severity: {risk.get('severity')}"
1113
+
1114
+ # Validate recommendations
1115
+ if not isinstance(output["recommendations"], list):
1116
+ return False, "Analysis could not be validated. Invalid recommendations format."
1117
+
1118
+ for i, rec in enumerate(output["recommendations"]):
1119
+ if not isinstance(rec, dict):
1120
+ return False, f"Analysis could not be validated. Invalid recommendation at index {i}."
1121
+ rec_required = ["action", "reason", "priority"]
1122
+ for rf in rec_required:
1123
+ if rf not in rec:
1124
+ return False, f"Analysis could not be validated. Recommendation {i} missing field: {rf}"
1125
+ if rec.get("priority") not in VALID_PRIORITY:
1126
+ return False, f"Analysis could not be validated. Invalid priority: {rec.get('priority')}"
1127
+
1128
+ # Validate overall_confidence
1129
+ if output.get("overall_confidence") not in VALID_CONFIDENCE:
1130
+ return False, f"Analysis could not be validated. Invalid overall_confidence."
1131
+
1132
+ return True, ""
1133
+
1134
+
1135
+ # ------------------------------------------------------------------
1136
+ # MAIN HANDLERS
1137
+ # ------------------------------------------------------------------
1138
+
1139
+ def init_app(composite_json: str):
1140
+ cache_entry = None
1141
+ browser_date = None
1142
+ browser_timezone = "UTC"
1143
+
1144
+ if composite_json and composite_json.strip() and composite_json != '{}':
1145
+ try:
1146
+ parsed = json.loads(composite_json)
1147
+ if isinstance(parsed, dict):
1148
+ # Format BARU: object composite dari JS
1149
+ if "browser_date" in parsed:
1150
+ cache_raw = parsed.get("cache", "{}")
1151
+ browser_date = parsed.get("browser_date")
1152
+ browser_timezone = parsed.get("browser_timezone", "UTC")
1153
+ if isinstance(cache_raw, str) and cache_raw.strip() and cache_raw != '{}':
1154
+ cache_parsed = json.loads(cache_raw)
1155
+ if isinstance(cache_parsed, dict) and "analysis" in cache_parsed:
1156
+ cache_entry = cache_parsed
1157
+ # Format LAMA: langsung cache entry (backward-compatible)
1158
+ elif "analysis" in parsed:
1159
+ cache_entry = parsed
1160
+ except Exception:
1161
+ cache_entry = None
1162
+
1163
+ # Fallback ke server time jika browser tidak mengirimkan waktu
1164
+ if not browser_date:
1165
+ now = datetime.now()
1166
+ browser_date = now.strftime("%Y-%m-%d")
1167
+ browser_timezone = "UTC"
1168
+
1169
+ # <-- KUNCI: bandingkan cache dengan TANGGAL BROWSER, bukan tanggal server
1170
+ today_cache = cache_entry if (
1171
+ cache_entry and cache_entry.get("client_date") == browser_date
1172
+ ) else None
1173
+
1174
+ state = AppState(
1175
+ state=STATE_READY,
1176
+ is_analyzing=False,
1177
+ client_date=browser_date,
1178
+ client_timezone=browser_timezone,
1179
+ browser_date=browser_date, # <-- BARU
1180
+ browser_timezone=browser_timezone, # <-- BARU
1181
+ cache=today_cache
1182
+ )
1183
+
1184
+ if today_cache:
1185
+ state.state = STATE_LOCKED_TODAY
1186
+ location = today_cache.get("location", {})
1187
+ lat = location.get("latitude", 0)
1188
+ lon = location.get("longitude", 0)
1189
+ analysis = today_cache.get("analysis", {})
1190
+
1191
+ return (
1192
+ state.to_dict(), lat, lon,
1193
+ gr.update(interactive=False), gr.update(interactive=False),
1194
+ format_status(STATE_LOCKED_TODAY, "Today's analysis already exists."),
1195
+ "",
1196
+ format_cache_info(today_cache),
1197
+ gr.update(visible=False),
1198
+ gr.update(visible=True),
1199
+ gr.update(visible=False),
1200
+ render_summary_tab(analysis),
1201
+ render_historical_tab(analysis),
1202
+ render_forecast_tab(analysis),
1203
+ render_risks_tab(analysis),
1204
+ render_recommendations_tab(analysis),
1205
+ gr.update(value="")
1206
+ )
1207
+ else:
1208
+ return (
1209
+ state.to_dict(), 0, 0,
1210
+ gr.update(interactive=True),
1211
+ gr.update(interactive=False),
1212
+ format_status(STATE_READY),
1213
+ "",
1214
+ "",
1215
+ gr.update(visible=True),
1216
+ gr.update(visible=False),
1217
+ gr.update(visible=False),
1218
+ render_summary_tab({}),
1219
+ render_historical_tab({}),
1220
+ render_forecast_tab({}),
1221
+ render_risks_tab({}),
1222
+ render_recommendations_tab({}),
1223
+ gr.update(value="")
1224
+ )
1225
+
1226
+
1227
+
1228
+ def handle_gps_result(gps_json: str, state_dict: Dict):
1229
+ state = AppState.from_dict(state_dict)
1230
+
1231
+ if not gps_json:
1232
+ return state.to_dict(), 0, 0, format_status(STATE_ERROR, "No location data received.")
1233
+
1234
+ try:
1235
+ data = json.loads(gps_json)
1236
+ except Exception:
1237
+ return state.to_dict(), 0, 0, format_status(STATE_ERROR, "Invalid location data.")
1238
+
1239
+ if "error" in data:
1240
+ return state.to_dict(), 0, 0, format_status(STATE_ERROR, data["error"])
1241
+
1242
+ lat = data.get("latitude")
1243
+ lon = data.get("longitude")
1244
+ source = data.get("source", "unknown")
1245
+ accuracy = data.get("accuracy_m")
1246
+
1247
+ valid, msg = validate_coordinates(lat, lon)
1248
+ if not valid:
1249
+ return state.to_dict(), 0, 0, format_status(STATE_ERROR, msg)
1250
+
1251
+ state.location = {
1252
+ "latitude": lat,
1253
+ "longitude": lon,
1254
+ "source": source,
1255
+ "accuracy_m": accuracy
1256
+ }
1257
+ state.state = STATE_LOCATION_READY
1258
+
1259
+ status = format_location_status(source, accuracy)
1260
+
1261
+ return state.to_dict(), lat, lon, status
1262
+
1263
+
1264
+ def handle_analyze(lat: float, lon: float, state_dict: Dict):
1265
+ state = AppState.from_dict(state_dict)
1266
+ cache_entry = state.cache # Single entry, not a dict
1267
+
1268
+ if state.is_analyzing:
1269
+ yield (
1270
+ state.to_dict(),
1271
+ gr.update(), gr.update(),
1272
+ format_status(STATE_ANALYZING, "Analysis already in progress."),
1273
+ "", "",
1274
+ render_placeholder("Analysis in progress", "Please wait for the current analysis to finish.", "active"),
1275
+ gr.update(value="")
1276
+ )
1277
+ return
1278
+
1279
+ valid, msg = validate_coordinates(lat, lon)
1280
+ if not valid:
1281
+ yield (
1282
+ state.to_dict(),
1283
+ gr.update(interactive=True), gr.update(interactive=True),
1284
+ format_status(STATE_ERROR, msg),
1285
+ "", "",
1286
+ render_placeholder("Invalid location", msg, "error"),
1287
+ gr.update(value="")
1288
+ )
1289
+ return
1290
+
1291
+ prev_source = (state.location or {}).get("source", "manual")
1292
+ prev_accuracy = (state.location or {}).get("accuracy_m")
1293
+ state.location = {
1294
+ "latitude": lat,
1295
+ "longitude": lon,
1296
+ "source": prev_source,
1297
+ "accuracy_m": prev_accuracy
1298
+ }
1299
+
1300
+ client_date = state.browser_date or datetime.now().strftime("%Y-%m-%d")
1301
+ state.client_date = client_date
1302
+
1303
+ # Check if today's analysis already exists in the single cache entry
1304
+ if cache_entry and cache_entry.get("client_date") == client_date:
1305
+ state.state = STATE_LOCKED_TODAY
1306
+ yield (
1307
+ state.to_dict(),
1308
+ gr.update(interactive=False), gr.update(interactive=False),
1309
+ format_status(STATE_LOCKED_TODAY, "Today's analysis already exists."),
1310
+ "",
1311
+ format_cache_info(cache_entry),
1312
+ render_analysis(cache_entry.get("analysis", {})),
1313
+ gr.update(value="")
1314
+ )
1315
+ return
1316
+
1317
+ state.is_analyzing = True
1318
+ state.state = STATE_ANALYZING
1319
+ yield (
1320
+ state.to_dict(),
1321
+ gr.update(interactive=False), gr.update(interactive=False),
1322
+ format_status(STATE_ANALYZING),
1323
+ format_progress(STATE_ANALYZING),
1324
+ "",
1325
+ render_placeholder("Starting analysis", "Preparing to fetch weather data...", "active"),
1326
+ gr.update(value="")
1327
+ )
1328
+
1329
+ state.state = STATE_FETCHING_WEATHER
1330
+ yield (
1331
+ state.to_dict(),
1332
+ gr.update(interactive=False), gr.update(interactive=False),
1333
+ format_status(STATE_FETCHING_WEATHER),
1334
+ format_progress(STATE_FETCHING_WEATHER),
1335
+ "",
1336
+ render_placeholder("Fetching weather data", "Retrieving historical and forecast records from Open-Meteo.", "active"),
1337
+ gr.update(value="")
1338
+ )
1339
+
1340
+ raw_weather, error = fetch_weather_data(lat, lon)
1341
+ if error:
1342
+ failed_step = state.state
1343
+ state.is_analyzing = False
1344
+ state.state = STATE_ERROR
1345
+ yield (
1346
+ state.to_dict(),
1347
+ gr.update(interactive=True), gr.update(interactive=True),
1348
+ format_status(STATE_ERROR, error),
1349
+ format_progress(failed_step, error=True), "",
1350
+ render_placeholder("Analysis failed", error, "error"),
1351
+ gr.update(value="")
1352
+ )
1353
+ return
1354
+
1355
+ state.state = STATE_PROCESSING_DATA
1356
+ yield (
1357
+ state.to_dict(),
1358
+ gr.update(interactive=False), gr.update(interactive=False),
1359
+ format_status(STATE_PROCESSING_DATA),
1360
+ format_progress(STATE_PROCESSING_DATA),
1361
+ "",
1362
+ render_placeholder("Processing weather data", "Splitting historical and forecast windows and computing derived indices.", "active"),
1363
+ gr.update(value="")
1364
+ )
1365
+
1366
+ normalized, error = normalize_weather_data(raw_weather, lat, lon, client_date)
1367
+ if error:
1368
+ failed_step = state.state
1369
+ state.is_analyzing = False
1370
+ state.state = STATE_ERROR
1371
+ yield (
1372
+ state.to_dict(),
1373
+ gr.update(interactive=True), gr.update(interactive=True),
1374
+ format_status(STATE_ERROR, error),
1375
+ format_progress(failed_step, error=True), "",
1376
+ render_placeholder("Analysis failed", error, "error"),
1377
+ gr.update(value="")
1378
+ )
1379
+ return
1380
+
1381
+ state.state = STATE_GENERATING_INSIGHT
1382
+ yield (
1383
+ state.to_dict(),
1384
+ gr.update(interactive=False), gr.update(interactive=False),
1385
+ format_status(STATE_GENERATING_INSIGHT),
1386
+ format_progress(STATE_GENERATING_INSIGHT),
1387
+ "",
1388
+ render_placeholder("Generating weather insight", "Gemini is interpreting the data for risks and recommendations.", "active"),
1389
+ gr.update(value="")
1390
+ )
1391
+
1392
+ payload = build_llm_payload(normalized, lat, lon, client_date)
1393
+ llm_output, error = call_gemini(payload)
1394
+ if error:
1395
+ failed_step = state.state
1396
+ state.is_analyzing = False
1397
+ state.state = STATE_ERROR
1398
+ yield (
1399
+ state.to_dict(),
1400
+ gr.update(interactive=True), gr.update(interactive=True),
1401
+ format_status(STATE_ERROR, error),
1402
+ format_progress(failed_step, error=True), "",
1403
+ render_placeholder("Analysis failed", error, "error"),
1404
+ gr.update(value="")
1405
+ )
1406
+ return
1407
+
1408
+ state.state = STATE_VALIDATING_OUTPUT
1409
+ yield (
1410
+ state.to_dict(),
1411
+ gr.update(interactive=False), gr.update(interactive=False),
1412
+ format_status(STATE_VALIDATING_OUTPUT),
1413
+ format_progress(STATE_VALIDATING_OUTPUT),
1414
+ "",
1415
+ render_placeholder("Validating analysis", "Checking the response against the expected schema.", "active"),
1416
+ gr.update(value="")
1417
+ )
1418
+
1419
+ valid, error = validate_llm_output(llm_output)
1420
+ if not valid:
1421
+ failed_step = state.state
1422
+ state.is_analyzing = False
1423
+ state.state = STATE_ERROR
1424
+ yield (
1425
+ state.to_dict(),
1426
+ gr.update(interactive=True), gr.update(interactive=True),
1427
+ format_status(STATE_ERROR, error),
1428
+ format_progress(failed_step, error=True), "",
1429
+ render_placeholder("Analysis failed", error, "error"),
1430
+ gr.update(value="")
1431
+ )
1432
+ return
1433
+
1434
+ state.state = STATE_COMPLETED
1435
+
1436
+ timezone_str = raw_weather.get("timezone", "UTC")
1437
+ location_date = normalized.get("meta", {}).get("location_date", client_date)
1438
+
1439
+ # SINGLE ENTRY: directly overwrite state.cache
1440
+ cache_entry = {
1441
+ "location": state.location,
1442
+ "client_date": client_date,
1443
+ "location_date": location_date,
1444
+ "location_timezone": timezone_str,
1445
+ "client_timezone": state.client_timezone or "UTC",
1446
+ "field_context": {
1447
+ "crop_type": crop,
1448
+ "phenology_phase": phenology,
1449
+ "field_notes": notes,
1450
+ "current_concern": current_concern,
1451
+ },
1452
+ "raw_weather": raw_weather,
1453
+ "analysis": llm_output,
1454
+ "created_at": datetime.now(timezone.utc).isoformat()
1455
+ }
1456
+
1457
+ state.cache = cache_entry
1458
+ state.is_analyzing = False
1459
+
1460
+ yield (
1461
+ state.to_dict(),
1462
+ gr.update(interactive=False), gr.update(interactive=False),
1463
+ format_status(STATE_COMPLETED),
1464
+ format_progress(STATE_COMPLETED),
1465
+ format_cache_info(cache_entry),
1466
+ render_analysis(llm_output),
1467
+ json.dumps(cache_entry) # cache_out: save single entry to browser
1468
+ )
1469
+
1470
+ state.state = STATE_LOCKED_TODAY
1471
+ yield (
1472
+ state.to_dict(),
1473
+ gr.update(interactive=False), gr.update(interactive=False),
1474
+ format_status(STATE_LOCKED_TODAY, "Today's analysis complete. Return tomorrow for a new analysis."),
1475
+ "",
1476
+ format_cache_info(cache_entry),
1477
+ render_analysis(llm_output),
1478
+ json.dumps(cache_entry) # cache_out: save single entry to browser
1479
+ )
1480
+
1481
+
1482
+ # ------------------------------------------------------------------
1483
+ # GRADIO UI (Gradio 6.x compatible)
1484
+ # ------------------------------------------------------------------
1485
+
1486
+ THEME = gr.themes.Base(
1487
+ font=[gr.themes.GoogleFont("IBM Plex Sans"), "sans-serif"],
1488
+ font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"],
1489
+ ).set(
1490
+ body_background_fill="#0B1B22",
1491
+ body_background_fill_dark="#0B1B22",
1492
+ body_text_color="#E7F1F0",
1493
+ body_text_color_dark="#E7F1F0",
1494
+ body_text_color_subdued="#7E9CA3",
1495
+ body_text_color_subdued_dark="#7E9CA3",
1496
+ background_fill_primary="#122631",
1497
+ background_fill_primary_dark="#122631",
1498
+ background_fill_secondary="#0F2229",
1499
+ background_fill_secondary_dark="#0F2229",
1500
+ border_color_primary="#23414F",
1501
+ border_color_primary_dark="#23414F",
1502
+ block_background_fill="#122631",
1503
+ block_background_fill_dark="#122631",
1504
+ block_border_color="#23414F",
1505
+ block_border_color_dark="#23414F",
1506
+ block_label_text_color="#7E9CA3",
1507
+ block_label_text_color_dark="#7E9CA3",
1508
+ block_label_background_fill="#122631",
1509
+ block_label_background_fill_dark="#122631",
1510
+ block_title_text_color="#E7F1F0",
1511
+ block_title_text_color_dark="#E7F1F0",
1512
+ panel_background_fill="#0F2229",
1513
+ panel_background_fill_dark="#0F2229",
1514
+ panel_border_color="#23414F",
1515
+ panel_border_color_dark="#23414F",
1516
+ input_background_fill="#0F2229",
1517
+ input_background_fill_dark="#0F2229",
1518
+ input_border_color="#23414F",
1519
+ input_border_color_dark="#23414F",
1520
+ input_border_color_focus="#E8A33D",
1521
+ input_border_color_focus_dark="#E8A33D",
1522
+ button_primary_background_fill="#E8A33D",
1523
+ button_primary_background_fill_dark="#E8A33D",
1524
+ button_primary_background_fill_hover="#F2B457",
1525
+ button_primary_background_fill_hover_dark="#F2B457",
1526
+ button_primary_text_color="#0B1B22",
1527
+ button_primary_text_color_dark="#0B1B22",
1528
+ button_primary_border_color="#E8A33D",
1529
+ button_primary_border_color_dark="#E8A33D",
1530
+ button_secondary_background_fill="#16303D",
1531
+ button_secondary_background_fill_dark="#16303D",
1532
+ button_secondary_background_fill_hover="#1B3945",
1533
+ button_secondary_background_fill_hover_dark="#1B3945",
1534
+ button_secondary_text_color="#E7F1F0",
1535
+ button_secondary_text_color_dark="#E7F1F0",
1536
+ button_secondary_border_color="#23414F",
1537
+ button_secondary_border_color_dark="#23414F",
1538
+ error_background_fill="#2A1714",
1539
+ error_background_fill_dark="#2A1714",
1540
+ error_border_color="#E2604F",
1541
+ error_border_color_dark="#E2604F",
1542
+ )
1543
+
1544
+ # ------------------------------------------------------------------
1545
+ # EXTERNAL ASSETS
1546
+ # ------------------------------------------------------------------
1547
+ CSS = load_text("assets/styles.css")
1548
+
1549
+ SPONSORS_RAW = load_json("assets/sponsors.json")
1550
+ SPONSORS_DATA = [(s["image"], s["name"]) for s in SPONSORS_RAW.get("sponsors", [])]
1551
+ SPONSOR_LINKS = [s["link"] for s in SPONSORS_RAW.get("sponsors", [])]
1552
+
1553
+ SPONSOR_HTML = render_sponsors_html(SPONSORS_RAW.get("sponsors", []))
1554
+
1555
+
1556
+ HEADER_HTML = HEADER_HTML = """
1557
+ <div class="hero-banner">
1558
+ <div class="app-header">
1559
+ <div class="eyebrow">Field Telemetry &middot; Weather Intelligence</div>
1560
+ <h1>DigiTanist/tanam</h1>
1561
+ <p class="subtitle">Cegah kerugian akibat cuaca buruk lebih lewat pantauan risiko harian berbasis rekam jejak dan prakiraan cuaca 14 hari. Ditenagai BMKG dan Gemini AI.</p>
1562
+ </div>
1563
+
1564
+ <div class="farmer-counter">
1565
+ <span class="farmer-counter-badge">
1566
+ <span class="farmer-counter-dot"></span>
1567
+ <span><strong id="farmer-count">—</strong> petani sudah menganalisis hari ini</span>
1568
+ </span>
1569
+ </div>
1570
+ </div>
1571
+ """
1572
+
1573
+ FOOTER_HTML = """
1574
+ <div class="app-footer">DATA &middot; OPEN-METEO&nbsp;&nbsp;|&nbsp;&nbsp;ANALYSIS &middot; GEMINI&nbsp;&nbsp;|&nbsp;&nbsp;ONE READ PER DAY</div>
1575
+ """
1576
+
1577
+
1578
+ GA4_HEAD = """
1579
+ <!-- Google tag (gtag.js) -->
1580
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-VWC31N398K"></script>
1581
+ <script>
1582
+ window.dataLayer = window.dataLayer || [];
1583
+ function gtag(){dataLayer.push(arguments);}
1584
+ gtag('js', new Date());
1585
+ gtag('config', 'G-VWC31N398K');
1586
+ </script>
1587
+
1588
+ <script>
1589
+ function getTodayFarmerCount() {
1590
+ // ====================== PENGATURAN ANGKA ======================
1591
+ const BASE_MIN = 220;
1592
+ const BASE_MAX = 380;
1593
+ const INCREASE_PER_10_MIN = 2; // Jumlah penambahan setiap 10 menit (bisa disesuaikan)
1594
+ // ==============================================================
1595
+
1596
+ const now = new Date();
1597
+
1598
+ const today = now.getFullYear() + '-' +
1599
+ String(now.getMonth() + 1).padStart(2, '0') + '-' +
1600
+ String(now.getDate()).padStart(2, '0');
1601
+
1602
+ let seed = 0;
1603
+ for (let i = 0; i < today.length; i++) {
1604
+ seed = (seed * 31 + today.charCodeAt(i)) & 0xffff;
1605
+ }
1606
+
1607
+ const base = BASE_MIN + (seed % (BASE_MAX - BASE_MIN + 1));
1608
+
1609
+ // 1. Hitung total menit yang sudah berlalu sejak pukul 00:00 hari ini
1610
+ const totalMinutes = (now.getHours() * 60) + now.getMinutes();
1611
+
1612
+ // 2. Hitung berapa banyak blok 10 menit yang sudah terlewati
1613
+ const blocksOf10Min = Math.floor(totalMinutes / 10);
1614
+
1615
+ // 3. Hitung tambahan angka berdasarkan jumlah blok 10 menit
1616
+ const extra = blocksOf10Min * INCREASE_PER_10_MIN;
1617
+
1618
+ return base + extra;
1619
+ }
1620
+
1621
+ function animateCount(el, target, duration = 3300) {
1622
+ const start = Math.max(0, target - Math.floor(target * 0.15)); // mulai dari ~85%
1623
+ const startTime = performance.now();
1624
+ function tick(now) {
1625
+ const progress = Math.min((now - startTime) / duration, 1);
1626
+ const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
1627
+ el.innerText = Math.round(start + (target - start) * eased).toLocaleString("id-ID");
1628
+ if (progress < 1) requestAnimationFrame(tick);
1629
+ }
1630
+ requestAnimationFrame(tick);
1631
+ }
1632
+
1633
+ function updateFarmerCount() {
1634
+ const el = document.getElementById("farmer-count");
1635
+ if (el) {
1636
+ animateCount(el, getTodayFarmerCount());
1637
+ return true; // berhasil
1638
+ }
1639
+ return false; // elemen belum ada
1640
+ }
1641
+
1642
+ // Coba terus sampai elemen muncul (maksimal 10 detik)
1643
+ function tryUpdateCounter(attempts = 0) {
1644
+ if (updateFarmerCount() || attempts > 20) return;
1645
+
1646
+ setTimeout(() => tryUpdateCounter(attempts + 1), 500);
1647
+ }
1648
+
1649
+ // Mulai mencoba segera
1650
+ tryUpdateCounter();
1651
+
1652
+ // Update rutin setiap 5 menit
1653
+ setInterval(updateFarmerCount, 5 * 60 * 1000);
1654
+ </script>
1655
+ """
1656
+
1657
+
1658
+ with gr.Blocks(title="Space Weather", head=GA4_HEAD) as demo:
1659
+
1660
+ gr.HTML(HEADER_HTML)
1661
+
1662
+ app_state = gr.State({})
1663
+ gps_data = gr.Textbox(visible=False, elem_id="gps_data_input")
1664
+ cache_in = gr.Textbox(visible=False, elem_id="browser_cache_in")
1665
+ cache_out = gr.Textbox(visible=False, elem_id="browser_cache_out")
1666
+ weather_json = gr.Textbox(visible=False, elem_id="weather_json_input")
1667
+
1668
+
1669
+ with gr.Row(elem_classes=["main-row"]):
1670
+ with gr.Column(scale=1, min_width=320, elem_classes=["rail-col"]):
1671
+ with gr.Column(elem_classes=["console-panel"]):
1672
+ gr.HTML('<div class="panel-eyebrow">Location</div>')
1673
+
1674
+ lat_input = gr.Number(
1675
+ label="Latitude",
1676
+ precision=6,
1677
+ value=0,
1678
+ info="-90 to 90"
1679
+ )
1680
+ lon_input = gr.Number(
1681
+ label="Longitude",
1682
+ precision=6,
1683
+ value=0,
1684
+ info="-180 to 180"
1685
+ )
1686
+
1687
+ crop_input = gr.Textbox(
1688
+ label="Jenis Tanaman",
1689
+ placeholder="Contoh: Padi, Jagung, Cabai",
1690
+ lines=1
1691
+ )
1692
+ phenology_input = gr.Textbox(
1693
+ label="Fase Fenologi",
1694
+ placeholder="Contoh: Vegetatif, Pembungaan, Pematangan",
1695
+ lines=1
1696
+ )
1697
+ notes_input = gr.Textbox(
1698
+ label="Catatan Lapangan",
1699
+ placeholder="Contoh: Ada genangan air, gejala serangan hama ringan",
1700
+ lines=2
1701
+ )
1702
+
1703
+ current_concern_input = gr.Textbox(
1704
+ label="Current Concern",
1705
+ placeholder="Contoh: Khawatir kekurangan air atau risiko penyakit jamur",
1706
+ info="Opsional. Masukkan kondisi atau pertanyaan yang ingin diperiksa berdasarkan cuaca.",
1707
+ lines=2
1708
+ )
1709
+
1710
+ with gr.Row(elem_classes=["btn-row"]):
1711
+ get_loc_btn = gr.Button("Ambil lokasi dari GPS perangkat", variant="secondary", size="sm")
1712
+ analyze_btn = gr.Button("Analyze", variant="primary", size="sm", interactive=False)
1713
+
1714
+
1715
+
1716
+
1717
+ with gr.Column():
1718
+ status_html = gr.HTML("")
1719
+ step_html = gr.HTML("")
1720
+ cache_html = gr.HTML("")
1721
+
1722
+
1723
+ with gr.Column(scale=2, elem_classes=["content-col"]):
1724
+ # Placeholder awal sebelum ada analisis atau saat loading
1725
+ initial_placeholder = gr.HTML(render_placeholder("Initializing", "Loading today's status..."))
1726
+
1727
+ # Tombol View Insight (awalnya disembunyikan)
1728
+ view_insight_btn = gr.Button("View Insight", variant="primary", visible=False, size="lg")
1729
+
1730
+ # Kontainer Tab dibungkus Column dan disembunyikan secara default (visible=False)
1731
+ with gr.Column(visible=False) as tabs_container:
1732
+ with gr.Tabs():
1733
+ with gr.TabItem("Summary"):
1734
+ summary_html = gr.HTML()
1735
+ with gr.TabItem("Historical"):
1736
+ historical_html = gr.HTML()
1737
+ with gr.TabItem("Forecast"):
1738
+ forecast_html = gr.HTML()
1739
+ with gr.TabItem("Risks"):
1740
+ risks_html = gr.HTML()
1741
+ with gr.TabItem("Recommendations"):
1742
+ recommendations_html = gr.HTML()
1743
+
1744
+
1745
+ # Tombol download data mentah (JSON): tersembunyi, muncul
1746
+ # bersamaan dengan tabs insight saat "View Insight" diklik.
1747
+ download_data_btn = gr.DownloadButton(
1748
+ "Download Data Analisis (TXT)",
1749
+ visible=False,
1750
+ size="sm",
1751
+ variant="secondary"
1752
+ )
1753
+
1754
+
1755
+ # Sponsor section (paling bawah)
1756
+ gr.HTML('<div style="height: 16px;"></div>')
1757
+ with gr.Column(elem_classes=["console-panel"]):
1758
+ gr.HTML('<div class="panel-eyebrow" style="padding: 14px 14px 6px;">Supported By</div>')
1759
+ gr.HTML(SPONSOR_HTML)
1760
+
1761
+
1762
+
1763
+
1764
+
1765
+
1766
+
1767
+
1768
+ gr.HTML(FOOTER_HTML)
1769
+
1770
+ # 1. Page load: read localStorage into hidden textbox
1771
+ # 1. Page load: baca localStorage + waktu browser, kirim sebagai composite JSON
1772
+ demo.load(
1773
+ fn=None,
1774
+ js="""() => {
1775
+ let cache = '{}';
1776
+ try {
1777
+ cache = localStorage.getItem('space_weather_cache') || '{}';
1778
+ } catch (e) {
1779
+ cache = '{}';
1780
+ }
1781
+ const now = new Date();
1782
+ const browserDate = now.getFullYear() + '-' +
1783
+ String(now.getMonth() + 1).padStart(2, '0') + '-' +
1784
+ String(now.getDate()).padStart(2, '0');
1785
+ const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
1786
+ return JSON.stringify({
1787
+ cache: cache,
1788
+ browser_date: browserDate,
1789
+ browser_timezone: browserTz
1790
+ });
1791
+ }""",
1792
+ outputs=cache_in
1793
+ )
1794
+
1795
+ # 2. When cache_in changes, initialize app from browser cache
1796
+ cache_in.change(
1797
+ fn=init_app,
1798
+ inputs=[cache_in],
1799
+ outputs=[
1800
+ app_state, lat_input, lon_input, get_loc_btn, analyze_btn,
1801
+ status_html, step_html, cache_html,
1802
+ initial_placeholder, view_insight_btn, tabs_container,
1803
+ summary_html, historical_html, forecast_html, risks_html, recommendations_html,
1804
+ cache_out
1805
+ ]
1806
+ )
1807
+
1808
+ # 3. Get Current Location -> JS geolocation -> hidden textbox
1809
+ get_loc_btn.click(
1810
+ fn=None,
1811
+ js="""
1812
+ async () => {
1813
+ return new Promise((resolve) => {
1814
+ if (navigator.geolocation) {
1815
+ navigator.geolocation.getCurrentPosition(
1816
+ (position) => {
1817
+ resolve(JSON.stringify({
1818
+ latitude: position.coords.latitude,
1819
+ longitude: position.coords.longitude,
1820
+ source: "gps",
1821
+ accuracy_m: position.coords.accuracy
1822
+ }));
1823
+ },
1824
+ async (error) => {
1825
+ try {
1826
+ const resp = await fetch('https://ipwho.is/');
1827
+ const data = await resp.json();
1828
+ if (data.success) {
1829
+ resolve(JSON.stringify({
1830
+ latitude: data.latitude,
1831
+ longitude: data.longitude,
1832
+ source: "ip",
1833
+ accuracy_m: null
1834
+ }));
1835
+ } else {
1836
+ resolve(JSON.stringify({
1837
+ error: "Unable to determine your location. Please enter coordinates manually."
1838
+ }));
1839
+ }
1840
+ } catch (e) {
1841
+ resolve(JSON.stringify({
1842
+ error: "Unable to determine your location. Please enter coordinates manually."
1843
+ }));
1844
+ }
1845
+ },
1846
+ {timeout: 10000, maximumAge: 60000}
1847
+ );
1848
+ } else {
1849
+ fetch('https://ipwho.is/')
1850
+ .then(r => r.json())
1851
+ .then(data => {
1852
+ if (data.success) {
1853
+ resolve(JSON.stringify({
1854
+ latitude: data.latitude,
1855
+ longitude: data.longitude,
1856
+ source: "ip",
1857
+ accuracy_m: null
1858
+ }));
1859
+ } else {
1860
+ resolve(JSON.stringify({
1861
+ error: "Unable to determine your location. Please enter coordinates manually."
1862
+ }));
1863
+ }
1864
+ })
1865
+ .catch(() => {
1866
+ resolve(JSON.stringify({
1867
+ error: "Unable to determine your location. Please enter coordinates manually."
1868
+ }));
1869
+ });
1870
+ }
1871
+ });
1872
+ }
1873
+ """,
1874
+ outputs=gps_data
1875
+ )
1876
+
1877
+ gps_data.change(
1878
+ fn=handle_gps_result,
1879
+ inputs=[gps_data, app_state],
1880
+ outputs=[app_state, lat_input, lon_input, status_html]
1881
+ )
1882
+
1883
+
1884
+
1885
+ analyze_btn.click(
1886
+ fn=None,
1887
+ js="""async (lat, lon) => {
1888
+ if (typeof gtag === 'function') {
1889
+ gtag('event', 'analyze_click', {
1890
+ 'event_category': 'engagement',
1891
+ 'latitude': lat,
1892
+ 'longitude': lon
1893
+ });
1894
+ }
1895
+ if (lat === null || lon === null || isNaN(lat) || isNaN(lon)) {
1896
+ return JSON.stringify({ error: "Invalid coordinates. Please enter a valid latitude and longitude." });
1897
+ }
1898
+
1899
+ const variables = [
1900
+ "temperature_2m_max", "temperature_2m_min", "rain_sum", "precipitation_sum",
1901
+ "wind_gusts_10m_max", "shortwave_radiation_sum", "temperature_2m_mean",
1902
+ "cloud_cover_mean", "et0_fao_evapotranspiration",
1903
+ "growing_degree_days_base_0_limit_50", "leaf_wetness_probability_mean",
1904
+ "vapour_pressure_deficit_max"
1905
+ ].join(",");
1906
+ const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&daily=${variables}&timezone=auto&past_days=7&forecast_days=7`;
1907
+ try {
1908
+ const resp = await fetch(url);
1909
+ if (!resp.ok) {
1910
+ return JSON.stringify({ error: `Weather data unavailable. Please try again later. (HTTP ${resp.status})` });
1911
+ }
1912
+ const data = await resp.json();
1913
+ return JSON.stringify(data);
1914
+ } catch (e) {
1915
+ return JSON.stringify({ error: "Weather data unavailable. Network error. Please try again later." });
1916
+ }
1917
+ }""",
1918
+ inputs=[lat_input, lon_input],
1919
+ outputs=[weather_json]
1920
+ )
1921
+
1922
+
1923
+
1924
+
1925
+ weather_json.change(
1926
+ fn=handle_weather_and_analyze,
1927
+ inputs=[weather_json, lat_input, lon_input, crop_input, phenology_input, notes_input, current_concern_input, app_state],
1928
+ outputs=[
1929
+ app_state, get_loc_btn, analyze_btn,
1930
+ status_html, step_html, cache_html,
1931
+ initial_placeholder, view_insight_btn, tabs_container,
1932
+ summary_html, historical_html, forecast_html, risks_html, recommendations_html,
1933
+ cache_out
1934
+ ]
1935
+ )
1936
+
1937
+
1938
+
1939
+ lat_input.change(
1940
+ fn=validate_coordinates_ui,
1941
+ inputs=[lat_input, lon_input, app_state],
1942
+ outputs=[analyze_btn]
1943
+ )
1944
+ lon_input.change(
1945
+ fn=validate_coordinates_ui,
1946
+ inputs=[lat_input, lon_input, app_state],
1947
+ outputs=[analyze_btn]
1948
+ )
1949
+
1950
+
1951
+ def handle_view_insight(state_dict):
1952
+ # Cukup tampilkan tabs + tombol download; file BELUM dibuat di
1953
+ # sini. File baru dirangkai saat tombol download benar-benar
1954
+ # diklik (lihat handle_download_click), supaya tidak ada file
1955
+ # bersama yang bisa tabrakan antar-user yang mengakses bersamaan.
1956
+ state = AppState.from_dict(state_dict)
1957
+ has_cache = bool(state.cache)
1958
+ return (
1959
+ gr.update(visible=False),
1960
+ gr.update(visible=True),
1961
+ gr.update(
1962
+ visible=has_cache,
1963
+ interactive=True,
1964
+ label="⬇ Download Data Analisis (TXT)"
1965
+ )
1966
+ )
1967
+
1968
+ view_insight_btn.click(
1969
+ fn=handle_view_insight,
1970
+ inputs=[app_state],
1971
+ js=f"""() => {{
1972
+ if (typeof gtag === 'function') {{
1973
+ gtag('event', 'view_insight_click', {{
1974
+ 'event_category': 'engagement'
1975
+ }});
1976
+ }}
1977
+ window.open('{SHOPEE_LINK}', '_blank');
1978
+ }}""",
1979
+ outputs=[view_insight_btn, tabs_container, download_data_btn]
1980
+ )
1981
+
1982
+ def handle_download_click(state_dict):
1983
+ # PENTING: gr.DownloadButton hanya memicu auto-download untuk
1984
+ # update value yang terjadi di DALAM event klik yang sama dengan
1985
+ # yang men-trigger-nya. Kalau proses "loading" dan "generate file"
1986
+ # dipecah jadi dua event terpisah (mis. via .then()), auto-download
1987
+ # hanya terpicu di klik BERIKUTNYA (karena value baru baru tersedia
1988
+ # setelah event klik pertama selesai) — itulah sebabnya butuh 2x
1989
+ # klik. Solusinya: satu fungsi generator, dua kali yield, tetap
1990
+ # dalam SATU event klik.
1991
+
1992
+ # Yield 1 (instan): kunci tombol + ubah label jadi indikator
1993
+ # loading, supaya user tahu proses berjalan dan tidak klik lagi.
1994
+ yield gr.update(interactive=False, label="⏳ Menyiapkan file...")
1995
+
1996
+ # Yield 2: file sudah jadi -> set value supaya auto-download
1997
+ # terpicu, lalu kembalikan tombol ke kondisi normal.
1998
+ state = AppState.from_dict(state_dict)
1999
+ export_path = build_export_file(state.cache)
2000
+ if export_path:
2001
+ label = "⬇ Download Data Analisis (TXT)"
2002
+ else:
2003
+ label = "⚠ Gagal membuat file, coba lagi"
2004
+ yield gr.update(value=export_path, interactive=True, label=label)
2005
+
2006
+ download_data_btn.click(
2007
+ fn=handle_download_click,
2008
+ inputs=[app_state],
2009
+ outputs=[download_data_btn]
2010
+ )
2011
+
2012
+
2013
+ # 4. When cache_out changes, save to localStorage (overwrite old data)
2014
+ cache_out.change(
2015
+ fn=None,
2016
+ js="""(data) => {
2017
+ if (data && data !== '{}' && data !== '') {
2018
+ try {
2019
+ // Always overwrite — never accumulate
2020
+ localStorage.setItem('space_weather_cache', data);
2021
+ } catch (e) {
2022
+ console.error('Failed to save cache to localStorage:', e);
2023
+ }
2024
+ }
2025
+ return [];
2026
+ }""",
2027
+ inputs=[cache_out]
2028
+ )
2029
+
2030
+
2031
+ if __name__ == "__main__":
2032
+ demo.launch(
2033
+ server_name="0.0.0.0",
2034
+ server_port=7860,
2035
+ theme=THEME,
2036
+ css=CSS,
2037
+ allowed_paths=["assets"]
2038
+ )