FajarHidaa commited on
Commit
64276f2
·
verified ·
1 Parent(s): fba6cd2

Upload 2 files

Browse files
Files changed (2) hide show
  1. app (5).py +1961 -0
  2. image_utils.py +42 -0
app (5).py ADDED
@@ -0,0 +1,1961 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ Fitur: foto lahan/tanaman opsional (dikompres di memori, tidak disimpan).
8
+ """
9
+
10
+ import html
11
+ import json
12
+ import logging
13
+ import math
14
+ import os
15
+ import random
16
+ import re
17
+ import time
18
+ import uuid
19
+ import warnings
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timezone
22
+ from typing import Any, Dict, List, Optional, Tuple
23
+
24
+ import gradio as gr
25
+ from litellm import completion
26
+
27
+ from gemini_key_manager import GeminiKeyManager, classify_gemini_error
28
+ from image_utils import compress_image_to_data_url
29
+ from product_search import build_recommendation_modal, preload
30
+
31
+ warnings.filterwarnings("ignore", category=RuntimeWarning, message="coroutine.*was never awaited")
32
+ warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*event loop.*")
33
+
34
+ logging.basicConfig(level=logging.INFO)
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ # ------------------------------------------------------------------
39
+ # FILE LOADERS
40
+ # ------------------------------------------------------------------
41
+ def load_text(path: str) -> str:
42
+ try:
43
+ with open(path, "r", encoding="utf-8") as f:
44
+ return f.read()
45
+ except Exception as e:
46
+ logger.error(f"Failed to load {path}: {e}")
47
+ return ""
48
+
49
+
50
+ def load_json(path: str) -> dict:
51
+ try:
52
+ with open(path, "r", encoding="utf-8") as f:
53
+ return json.load(f)
54
+ except Exception as e:
55
+ logger.error(f"Failed to load {path}: {e}")
56
+ return {}
57
+
58
+
59
+ _SYSTEM_PROMPT: Optional[str] = None
60
+
61
+
62
+ def get_system_prompt() -> str:
63
+ """System prompt dibaca sekali lalu di-cache di memori."""
64
+ global _SYSTEM_PROMPT
65
+ if not _SYSTEM_PROMPT:
66
+ _SYSTEM_PROMPT = load_text("prompts/system_prompt.txt")
67
+ return _SYSTEM_PROMPT
68
+
69
+
70
+ # ------------------------------------------------------------------
71
+ # CONFIGURATION
72
+ # ------------------------------------------------------------------
73
+ EXPORT_DIR = "/tmp/weather_exports"
74
+ EXPORT_MAX_AGE_S = 3600
75
+
76
+ SHOPEE_LINK = "https://shopee.co.id/"
77
+
78
+ GEMINI_MODEL = "gemini/gemini-3.5-flash"
79
+ GEMINI_TIMEOUT_S = 30
80
+ GEMINI_TIMEOUT_IMAGE_S = 45
81
+ GEMINI_TOTAL_BUDGET_S = 100 # batas total waktu satu analisis (semua key + retry)
82
+ GEMINI_MAX_ROUNDS = 3 # putaran retry jika kena limit per menit (RPM)
83
+
84
+ CONCURRENCY_LIMIT = 20 # analisis Gemini paralel
85
+ MAX_UPLOAD_SIZE = "8mb"
86
+
87
+ key_manager = GeminiKeyManager()
88
+ preload() # load model & embedding produk di background thread
89
+
90
+ # ------------------------------------------------------------------
91
+ # STATE MACHINE CONSTANTS
92
+ # ------------------------------------------------------------------
93
+ STATE_READY = "READY"
94
+ STATE_LOCATING = "LOCATING"
95
+ STATE_LOCATION_READY = "LOCATION_READY"
96
+ STATE_ANALYZING = "ANALYZING"
97
+ STATE_FETCHING_WEATHER = "FETCHING_WEATHER"
98
+ STATE_PROCESSING_DATA = "PROCESSING_DATA"
99
+ STATE_GENERATING_INSIGHT = "GENERATING_INSIGHT"
100
+ STATE_VALIDATING_OUTPUT = "VALIDATING_OUTPUT"
101
+ STATE_COMPLETED = "COMPLETED"
102
+ STATE_ERROR = "ERROR"
103
+ STATE_LOCKED_TODAY = "LOCKED_TODAY"
104
+
105
+ PROGRESS_STEPS = {
106
+ STATE_FETCHING_WEATHER: ("Step 1/5", "Getting weather data..."),
107
+ STATE_PROCESSING_DATA: ("Step 2/5", "Processing weather data..."),
108
+ STATE_GENERATING_INSIGHT: ("Step 3/5", "Generating weather insight..."),
109
+ STATE_VALIDATING_OUTPUT: ("Step 4/5", "Validating analysis..."),
110
+ STATE_COMPLETED: ("Step 5/5", "Analysis complete"),
111
+ }
112
+
113
+ STEP_ORDER = [
114
+ STATE_FETCHING_WEATHER,
115
+ STATE_PROCESSING_DATA,
116
+ STATE_GENERATING_INSIGHT,
117
+ STATE_VALIDATING_OUTPUT,
118
+ STATE_COMPLETED,
119
+ ]
120
+ STEP_LABELS = ["Weather Data", "Processing", "Insight", "Validation", "Complete"]
121
+
122
+ STATUS_VARIANTS = {
123
+ STATE_READY: ("neutral", "Ready"),
124
+ STATE_LOCATION_READY: ("info", "Location ready"),
125
+ STATE_ANALYZING: ("active", "Analyzing"),
126
+ STATE_ERROR: ("error", "Error"),
127
+ STATE_LOCKED_TODAY: ("info", "Locked"),
128
+ STATE_COMPLETED: ("success", "Complete"),
129
+ }
130
+
131
+ VALID_SEVERITY = {"low", "medium", "high"}
132
+ VALID_CONFIDENCE = {"low", "medium", "high"}
133
+ VALID_PRIORITY = {"low", "medium", "high"}
134
+
135
+ WEATHER_VARIABLES = [
136
+ # Temperature and plant development
137
+ "temperature_2m_max",
138
+ "temperature_2m_min",
139
+ "temperature_2m_mean",
140
+ "growing_degree_days_base_0_limit_50",
141
+
142
+ # Rainfall and water balance
143
+ "precipitation_sum",
144
+ "precipitation_hours",
145
+ "precipitation_probability_max",
146
+ "et0_fao_evapotranspiration",
147
+
148
+ # Humidity and disease-related conditions
149
+ "relative_humidity_2m_mean",
150
+ "relative_humidity_2m_max",
151
+ "dew_point_2m_mean",
152
+ "leaf_wetness_probability_mean",
153
+ "vapour_pressure_deficit_max",
154
+
155
+ # Radiation, clouds, and wind
156
+ "shortwave_radiation_sum",
157
+ "cloud_cover_mean",
158
+ "wind_speed_10m_max",
159
+ "wind_gusts_10m_max",
160
+ ]
161
+
162
+
163
+ # ------------------------------------------------------------------
164
+ # DATA CLASSES
165
+ # ------------------------------------------------------------------
166
+ @dataclass
167
+ class AppState:
168
+ state: str = STATE_READY
169
+ is_analyzing: bool = False
170
+ location: Optional[Dict] = None
171
+ client_date: Optional[str] = None
172
+ client_timezone: Optional[str] = None
173
+ browser_date: Optional[str] = None
174
+ browser_timezone: Optional[str] = None
175
+ cache: Optional[Dict] = None # SINGLE entry
176
+
177
+ def to_dict(self):
178
+ return {
179
+ "state": self.state,
180
+ "is_analyzing": self.is_analyzing,
181
+ "location": self.location,
182
+ "client_date": self.client_date,
183
+ "client_timezone": self.client_timezone,
184
+ "browser_date": self.browser_date,
185
+ "browser_timezone": self.browser_timezone,
186
+ "cache": self.cache,
187
+ }
188
+
189
+ @classmethod
190
+ def from_dict(cls, d):
191
+ d = d or {}
192
+ return cls(
193
+ state=d.get("state", STATE_READY),
194
+ is_analyzing=d.get("is_analyzing", False),
195
+ location=d.get("location"),
196
+ client_date=d.get("client_date"),
197
+ client_timezone=d.get("client_timezone"),
198
+ browser_date=d.get("browser_date"),
199
+ browser_timezone=d.get("browser_timezone"),
200
+ cache=d.get("cache"),
201
+ )
202
+
203
+
204
+ # ------------------------------------------------------------------
205
+ # UTILITY FORMATTERS
206
+ # ------------------------------------------------------------------
207
+ def validate_coordinates(lat: Any, lon: Any) -> Tuple[bool, str]:
208
+ if lat is None or lon is None:
209
+ return False, "Invalid location. Please enter a valid latitude and longitude."
210
+ try:
211
+ lat_f = float(lat)
212
+ lon_f = float(lon)
213
+ except (ValueError, TypeError):
214
+ return False, "Invalid location. Please enter a valid latitude and longitude."
215
+
216
+ if not (-90 <= lat_f <= 90):
217
+ return False, "Invalid location. Latitude must be between -90 and 90."
218
+ if not (-180 <= lon_f <= 180):
219
+ return False, "Invalid location. Longitude must be between -180 and 180."
220
+ return True, ""
221
+
222
+
223
+ def validate_coordinates_ui(lat, lon, state_dict):
224
+ state = AppState.from_dict(state_dict)
225
+ if state.state == STATE_LOCKED_TODAY:
226
+ return gr.update(interactive=False)
227
+ if (lat is None or lat == 0) or (lon is None or lon == 0):
228
+ return gr.update(interactive=False)
229
+ return gr.update(interactive=True)
230
+
231
+
232
+ def esc(value: Any) -> str:
233
+ if value is None:
234
+ return ""
235
+ return html.escape(str(value))
236
+
237
+
238
+ def format_status(state: str, message: str = "") -> str:
239
+ if state in PROGRESS_STEPS and state not in STATUS_VARIANTS:
240
+ variant = "active"
241
+ label = PROGRESS_STEPS[state][1]
242
+ else:
243
+ variant, label = STATUS_VARIANTS.get(state, ("neutral", state.replace("_", " ").title()))
244
+
245
+ detail = f'<span class="status-detail">{esc(message)}</span>' if message else ""
246
+ return (
247
+ f'<div class="status-pill status-pill--{variant}">'
248
+ f'<span class="status-dot"></span>'
249
+ f'<span class="status-label">{esc(label)}</span>'
250
+ f'{detail}'
251
+ f'</div>'
252
+ )
253
+
254
+
255
+ def format_location_status(source: str, accuracy: Optional[float]) -> str:
256
+ source_label = {"gps": "GPS", "ip": "IP geolocation"}.get(source, source or "Manual")
257
+ accuracy_str = f" &plusmn;{accuracy:.0f}m" if accuracy else ""
258
+ return (
259
+ f'<div class="status-pill status-pill--info">'
260
+ f'<span class="status-dot"></span>'
261
+ f'<span class="status-label">Location ready</span>'
262
+ f'<span class="status-detail">{esc(source_label)}{accuracy_str}</span>'
263
+ f'</div>'
264
+ )
265
+
266
+
267
+ def render_step_tracker(state: str, error: bool = False) -> str:
268
+ if state not in STEP_ORDER:
269
+ return ""
270
+ idx = STEP_ORDER.index(state)
271
+
272
+ items = []
273
+ for i, label in enumerate(STEP_LABELS):
274
+ is_done = i < idx or (i == idx and state == STATE_COMPLETED)
275
+ if error and i == idx:
276
+ cls, marker = "is-error", "&times;"
277
+ elif is_done:
278
+ cls, marker = "is-done", "&check;"
279
+ elif i == idx:
280
+ cls, marker = "is-active", f"{i + 1:02d}"
281
+ else:
282
+ cls, marker = "is-pending", f"{i + 1:02d}"
283
+
284
+ items.append(
285
+ f'<div class="step {cls}">'
286
+ f'<span class="step-marker">{marker}</span>'
287
+ f'<span class="step-label">{esc(label)}</span>'
288
+ f'</div>'
289
+ )
290
+ if i < len(STEP_LABELS) - 1:
291
+ filled = "filled" if is_done else ""
292
+ items.append(f'<div class="step-connector {filled}"></div>')
293
+
294
+ return f'<div class="step-tracker">{"".join(items)}</div>'
295
+
296
+
297
+ def format_progress(state: str, error: bool = False) -> str:
298
+ return render_step_tracker(state, error=error)
299
+
300
+
301
+ def format_cache_info(cache_entry: Optional[Dict]) -> str:
302
+ if not cache_entry:
303
+ return ""
304
+
305
+ client_date = cache_entry.get("client_date", "Unknown")
306
+ if client_date != "Unknown":
307
+ try:
308
+ client_date = datetime.strptime(client_date, "%Y-%m-%d").strftime("%d %b %Y")
309
+ except Exception:
310
+ pass
311
+
312
+ created = cache_entry.get("created_at", "Unknown")
313
+ browser_tz = cache_entry.get("browser_timezone") or cache_entry.get("client_timezone") or "UTC"
314
+
315
+ if created != "Unknown":
316
+ try:
317
+ dt = datetime.fromisoformat(created)
318
+ if dt.tzinfo is None:
319
+ dt = dt.replace(tzinfo=timezone.utc)
320
+ if browser_tz and browser_tz != "UTC":
321
+ try:
322
+ from zoneinfo import ZoneInfo
323
+ dt_local = dt.astimezone(ZoneInfo(browser_tz))
324
+ created = dt_local.strftime("%d %b %Y, %H:%M") + f" · {browser_tz}"
325
+ except Exception:
326
+ created = dt.strftime("%d %b %Y, %H:%M UTC")
327
+ else:
328
+ created = dt.strftime("%d %b %Y, %H:%M UTC")
329
+ except Exception:
330
+ pass
331
+
332
+ location = cache_entry.get("location", {}) or {}
333
+ source = location.get("source", "manual")
334
+ source_label = {"gps": "GPS", "ip": "IP Geolocation", "manual": "Manual"}.get(source, source)
335
+
336
+ return (
337
+ f'<div class="cache-card">'
338
+ f'<div class="cache-card-icon">&check;</div>'
339
+ f'<div class="cache-card-body">'
340
+ f'<div class="cache-card-title">Today&rsquo;s analysis is ready</div>'
341
+ f'<div class="cache-card-meta">'
342
+ f'<span>{esc(client_date)}</span><span class="dot-sep">&middot;</span><span>{esc(source_label)}</span>'
343
+ f'</div>'
344
+ f'<div class="cache-card-meta cache-card-meta--faint">Generated {esc(created)}</div>'
345
+ f'</div>'
346
+ f'</div>'
347
+ )
348
+
349
+
350
+ def render_placeholder(title: str, message: str, variant: str = "neutral") -> str:
351
+ return (
352
+ f'<div class="placeholder-card placeholder-card--{variant}">'
353
+ f'<div class="placeholder-mark"></div>'
354
+ f'<div class="placeholder-title">{esc(title)}</div>'
355
+ f'<div class="placeholder-message">{esc(message)}</div>'
356
+ f'</div>'
357
+ )
358
+
359
+
360
+ def render_sponsors_html(sponsors: List[Dict]) -> str:
361
+ if not sponsors:
362
+ return '<div class="sponsor-empty">No sponsors listed.</div>'
363
+ items = []
364
+ for s in sponsors:
365
+ items.append(
366
+ f'<a class="sponsor-item" href="{esc(s.get("link", "#"))}" '
367
+ f'target="_blank" rel="noopener noreferrer" title="{esc(s.get("name", ""))}">'
368
+ f'<img class="sponsor-img" src="{esc(s.get("image", ""))}" '
369
+ f'alt="{esc(s.get("name", ""))}" loading="lazy">'
370
+ f'</a>'
371
+ )
372
+ return f'<div class="sponsor-grid">{"".join(items)}</div>'
373
+
374
+
375
+ # ------------------------------------------------------------------
376
+ # TAB RENDERERS
377
+ # ------------------------------------------------------------------
378
+ _LEVEL_CLASS = {"low": "badge--good", "medium": "badge--warn", "high": "badge--bad"}
379
+
380
+
381
+ def severity_badge(sev: Optional[str]) -> str:
382
+ sev_l = (sev or "unknown").lower()
383
+ return f'<span class="badge {_LEVEL_CLASS.get(sev_l, "badge--neutral")}">{esc(sev_l.upper())}</span>'
384
+
385
+
386
+ def priority_badge(pri: Optional[str]) -> str:
387
+ pri_l = (pri or "unknown").lower()
388
+ return f'<span class="badge {_LEVEL_CLASS.get(pri_l, "badge--neutral")}">{esc(pri_l.upper())} PRI.</span>'
389
+
390
+
391
+ def confidence_badge(conf: Optional[str]) -> str:
392
+ conf_l = (conf or "unknown").lower()
393
+ cls = {"high": "badge--good", "medium": "badge--warn", "low": "badge--neutral"}.get(conf_l, "badge--neutral")
394
+ return f'<span class="badge {cls} badge--outline">{esc(conf_l.upper())} CONF.</span>'
395
+
396
+
397
+ def render_summary_tab(analysis: Dict) -> str:
398
+ if not analysis:
399
+ return render_placeholder("No analysis yet", "Enter coordinates and select Analyze to generate today's insight.")
400
+ parts = ['<div class="insight-card">']
401
+ parts.append(f'<p class="insight-summary">{esc(analysis.get("summary", "No summary available."))}</p>')
402
+ overall = analysis.get("overall_confidence", "")
403
+ if overall:
404
+ parts.append(f'<div class="insight-footer">Overall confidence&nbsp;{confidence_badge(overall)}</div>')
405
+ parts.append('</div>')
406
+ return "".join(parts)
407
+
408
+
409
+ def _render_period_tab(analysis: Dict, key: str, eyebrow: str, empty_title: str,
410
+ empty_msg: str, nothing_msg: str) -> str:
411
+ """Dipakai bersama oleh tab Historical dan Forecast (mendukung format lama)."""
412
+ if not analysis:
413
+ return render_placeholder(empty_title, empty_msg)
414
+ block = analysis.get(key, {}) or {}
415
+ parts = ['<div class="insight-card"><div class="insight-section">']
416
+ parts.append(f'<div class="section-eyebrow">{eyebrow}</div>')
417
+ if block.get("condition"):
418
+ parts.append(f'<p class="section-text"><strong>Kondisi:</strong> {esc(block["condition"])}</p>')
419
+ if block.get("impact"):
420
+ parts.append(f'<p class="section-text"><strong>Dampak:</strong> {esc(block["impact"])}</p>')
421
+ # Format lama (backward-compatible)
422
+ if block.get("summary"):
423
+ parts.append(f'<p class="section-text">{esc(block["summary"])}</p>')
424
+ conds = block.get("key_conditions") or []
425
+ if conds:
426
+ parts.append('<ul class="condition-list">' + "".join(f'<li>{esc(c)}</li>' for c in conds) + '</ul>')
427
+ if not (block.get("condition") or block.get("impact") or block.get("summary") or conds):
428
+ parts.append(f'<p class="section-text">{nothing_msg}</p>')
429
+ parts.append('</div></div>')
430
+ return "".join(parts)
431
+
432
+
433
+ def render_historical_tab(analysis: Dict) -> str:
434
+ return _render_period_tab(
435
+ analysis, "historical", "Historical &middot; Last 7 Days",
436
+ "No data yet", "Run analysis to view historical records.", "No historical data recorded.",
437
+ )
438
+
439
+
440
+ def render_forecast_tab(analysis: Dict) -> str:
441
+ return _render_period_tab(
442
+ analysis, "forecast", "Forecast &middot; Next 7 Days",
443
+ "No data yet", "Run analysis to view forecast records.", "No forecast data recorded.",
444
+ )
445
+
446
+
447
+ def render_risks_tab(analysis: Dict) -> str:
448
+ if not analysis:
449
+ return render_placeholder("No data yet", "Run analysis to view risk signals.")
450
+ risks = analysis.get("risks") or []
451
+ parts = ['<div class="insight-card"><div class="insight-section">']
452
+ parts.append('<div class="section-eyebrow">Risk Signals</div>')
453
+ if risks:
454
+ parts.append('<div class="risk-list">')
455
+ for risk in risks:
456
+ title = str(risk.get("title") or risk.get("type", "Unknown Risk"))
457
+ parts.append('<div class="risk-item">')
458
+ parts.append(
459
+ f'<div class="risk-item-head">'
460
+ f'<span class="risk-item-title">{esc(title)}</span>'
461
+ f'<span class="risk-item-badges">{severity_badge(risk.get("severity"))}</span>'
462
+ f'</div>'
463
+ )
464
+ if risk.get("description"):
465
+ parts.append(f'<p class="section-text" style="margin:6px 0 4px;font-size:13px;">{esc(risk["description"])}</p>')
466
+ if risk.get("evidence"):
467
+ parts.append(f'<p class="risk-item-evidence">{esc(risk["evidence"])}</p>')
468
+ if risk.get("period"):
469
+ parts.append(f'<p class="risk-item-evidence" style="color:var(--info);margin-top:4px;">&#128197; {esc(risk["period"])}</p>')
470
+ parts.append('</div>')
471
+ parts.append('</div>')
472
+ else:
473
+ parts.append('<p class="section-text">No significant weather risks detected.</p>')
474
+ parts.append('</div></div>')
475
+ return "".join(parts)
476
+
477
+
478
+ def render_recommendations_tab(analysis: Dict) -> str:
479
+ if not analysis:
480
+ return render_placeholder("No data yet", "Run analysis to view recommendations.")
481
+ parts = ['<div class="insight-card">']
482
+ parts.append('<div class="insight-section insight-section--recommendation" style="margin-bottom:0;">')
483
+ parts.append('<div class="section-eyebrow">Recommendation</div>')
484
+ recs = analysis.get("recommendations") or []
485
+ if recs:
486
+ parts.append('<div class="risk-list">')
487
+ for rec in recs:
488
+ if isinstance(rec, str): # format lama
489
+ action, reason, priority = rec, "", ""
490
+ else:
491
+ action = str(rec.get("action", ""))
492
+ reason = str(rec.get("reason", ""))
493
+ priority = rec.get("priority", "")
494
+ parts.append('<div class="risk-item" style="border-color:rgba(232,163,61,0.25);">')
495
+ parts.append(
496
+ f'<div class="risk-item-head">'
497
+ f'<span class="risk-item-title">{esc(action)}</span>'
498
+ f'<span class="risk-item-badges">{priority_badge(priority)}</span>'
499
+ f'</div>'
500
+ )
501
+ if reason:
502
+ parts.append(f'<p class="risk-item-evidence">{esc(reason)}</p>')
503
+ parts.append('</div>')
504
+ parts.append('</div>')
505
+ else:
506
+ parts.append('<p class="section-text">No immediate action indicated.</p>')
507
+ parts.append('</div></div>')
508
+ return "".join(parts)
509
+
510
+
511
+ def render_all_tabs(analysis: Dict) -> Tuple[str, str, str, str, str]:
512
+ return (
513
+ render_summary_tab(analysis),
514
+ render_historical_tab(analysis),
515
+ render_forecast_tab(analysis),
516
+ render_risks_tab(analysis),
517
+ render_recommendations_tab(analysis),
518
+ )
519
+
520
+
521
+ # ------------------------------------------------------------------
522
+ # UI OUTPUT HELPERS (satu tempat untuk tuple 15 elemen)
523
+ # ------------------------------------------------------------------
524
+ def tabs_placeholder(title: str, message: str, variant: str = "neutral",
525
+ rest: str = "Waiting...") -> Tuple[str, str, str, str, str]:
526
+ first = render_placeholder(title, message, variant)
527
+ other = render_placeholder("No data yet", rest, "neutral")
528
+ return (first, other, other, other, other)
529
+
530
+
531
+ def ui_outputs(state: AppState, *, lock_inputs: bool, status: str, step: str = "",
532
+ cache_info: str = "", show_placeholder: bool, show_view_btn: bool,
533
+ tabs: Tuple[str, str, str, str, str], cache_json: str = ""):
534
+ """Urutan harus cocok dengan `outputs` pada weather_json.change."""
535
+ return (
536
+ state.to_dict(),
537
+ gr.update(interactive=not lock_inputs), # get_loc_btn
538
+ gr.update(interactive=not lock_inputs), # analyze_btn
539
+ status,
540
+ step,
541
+ cache_info,
542
+ gr.update(visible=show_placeholder), # initial_placeholder
543
+ gr.update(visible=show_view_btn), # view_insight_btn
544
+ gr.update(visible=False), # tabs_container (baru tampil setelah View Insight)
545
+ *tabs,
546
+ cache_json if cache_json else gr.update(value=""),
547
+ )
548
+
549
+
550
+ def progress_outputs(state: AppState, step_state: str, title: str, message: str):
551
+ state.state = step_state
552
+ return ui_outputs(
553
+ state, lock_inputs=True,
554
+ status=format_status(step_state), step=format_progress(step_state),
555
+ show_placeholder=True, show_view_btn=False,
556
+ tabs=tabs_placeholder(title, message, "active"),
557
+ )
558
+
559
+
560
+ def error_outputs(state: AppState, failed_step: str, message: str):
561
+ state.is_analyzing = False
562
+ state.state = STATE_ERROR
563
+ step = format_progress(failed_step if failed_step in STEP_ORDER else STATE_FETCHING_WEATHER, error=True)
564
+ return ui_outputs(
565
+ state, lock_inputs=False,
566
+ status=format_status(STATE_ERROR, message), step=step,
567
+ show_placeholder=True, show_view_btn=False,
568
+ tabs=tabs_placeholder("Analysis failed", message, "error", "Analysis interrupted."),
569
+ )
570
+
571
+
572
+ # ------------------------------------------------------------------
573
+ # WEATHER VALIDATION & FEATURE ENGINEERING
574
+ # ------------------------------------------------------------------
575
+ MAX_DAILY_ROWS = 40
576
+
577
+
578
+ def validate_weather_payload(raw: Any) -> str:
579
+ """Respons cuaca datang dari browser, jadi struktur harus diperiksa di server."""
580
+ if not isinstance(raw, dict):
581
+ return "Weather data unavailable. Please try again later. (Invalid JSON)"
582
+ daily = raw.get("daily")
583
+ if not isinstance(daily, dict):
584
+ return "Weather data unavailable. Please try again later. (Missing daily data)"
585
+ dates = daily.get("time")
586
+ if not isinstance(dates, list) or len(dates) < 8:
587
+ return "Weather data unavailable. Please try again later. (Insufficient data)"
588
+ if len(dates) > MAX_DAILY_ROWS:
589
+ return "Weather data unavailable. Please try again later. (Too many rows)"
590
+ for var in WEATHER_VARIABLES:
591
+ values = daily.get(var)
592
+ if not isinstance(values, list) or len(values) != len(dates):
593
+ return f"Weather data unavailable. Please try again later. (Missing variable: {var})"
594
+ return ""
595
+
596
+
597
+ def _weather_num(value):
598
+ """Convert valid finite numeric values to float; otherwise None."""
599
+ try:
600
+ if value is None:
601
+ return None
602
+ number = float(value)
603
+ return number if math.isfinite(number) else None
604
+ except (TypeError, ValueError):
605
+ return None
606
+
607
+
608
+ def _weather_probability_fraction(value, unit=None):
609
+ """Normalize a probability to 0-1 (Open-Meteo biasanya dalam persen)."""
610
+ number = _weather_num(value)
611
+ if number is None:
612
+ return None
613
+ if unit and "%" in str(unit):
614
+ number /= 100.0
615
+ elif number > 1.0:
616
+ number /= 100.0
617
+ return max(0.0, min(1.0, number))
618
+
619
+
620
+ def _get(record: Dict, source: str, key: str):
621
+ return record["raw"].get(key) if source == "raw" else record["features"].get(key)
622
+
623
+
624
+ def engineer_weather_features(normalized):
625
+ """
626
+ Build derived agricultural weather features from the daily arrays.
627
+ Output: feature arrays per periode + ringkasan periode.
628
+ """
629
+ units = normalized.get("units", {}) or {}
630
+ periods = ("historical", "today", "forecast")
631
+ records = []
632
+
633
+ # 1. Gabungkan tiga periode secara kronologis
634
+ for period in periods:
635
+ block = normalized.get(period, {}) or {}
636
+ dates = block.get("time", []) or []
637
+
638
+ for i, date in enumerate(dates):
639
+ raw = {}
640
+ for variable in WEATHER_VARIABLES:
641
+ values = block.get(variable, [])
642
+ value = values[i] if isinstance(values, list) and i < len(values) else None
643
+ raw[variable] = _weather_num(value)
644
+
645
+ rh = raw.get("relative_humidity_2m_mean")
646
+ leaf_wetness = _weather_probability_fraction(
647
+ raw.get("leaf_wetness_probability_mean"),
648
+ units.get("leaf_wetness_probability_mean"),
649
+ )
650
+ precip_probability = _weather_probability_fraction(
651
+ raw.get("precipitation_probability_max"),
652
+ units.get("precipitation_probability_max"),
653
+ )
654
+
655
+ t_mean = raw.get("temperature_2m_mean")
656
+ t_min = raw.get("temperature_2m_min")
657
+ t_max = raw.get("temperature_2m_max")
658
+ dew = raw.get("dew_point_2m_mean")
659
+ precip = raw.get("precipitation_sum")
660
+ precip_hours = raw.get("precipitation_hours")
661
+ et0 = raw.get("et0_fao_evapotranspiration")
662
+
663
+ features = {
664
+ "temperature_range_c": (
665
+ t_max - t_min if t_max is not None and t_min is not None else None
666
+ ),
667
+ "dewpoint_depression_proxy_c": (
668
+ t_mean - dew if t_mean is not None and dew is not None else None
669
+ ),
670
+ "water_balance_proxy_mm": (
671
+ precip - et0 if precip is not None and et0 is not None else None
672
+ ),
673
+ "precipitation_per_wet_hour_proxy": (
674
+ precip / precip_hours
675
+ if precip is not None and precip_hours is not None and precip_hours > 0
676
+ else None
677
+ ),
678
+ "leaf_wetness_fraction": leaf_wetness,
679
+ "precip_probability_fraction": precip_probability,
680
+ # Proksi lingkungan, BUKAN probabilitas penyakit.
681
+ "humidity_wetness_interaction": (
682
+ (rh / 100.0) * leaf_wetness
683
+ if rh is not None and leaf_wetness is not None
684
+ else None
685
+ ),
686
+ "wetness_duration_interaction": (
687
+ leaf_wetness * max(0.0, min(1.0, precip_hours / 24.0))
688
+ if leaf_wetness is not None and precip_hours is not None
689
+ else None
690
+ ),
691
+ }
692
+ records.append({"date": date, "period": period, "raw": raw, "features": features})
693
+
694
+ # 2. Hari berturut-turut dengan presipitasi <= 0.1 mm
695
+ dry_streak = 0
696
+ for record in records:
697
+ precip = record["raw"].get("precipitation_sum")
698
+ if precip is None:
699
+ dry_streak = 0
700
+ record["features"]["dry_spell_days"] = None
701
+ elif precip <= 0.1:
702
+ dry_streak += 1
703
+ record["features"]["dry_spell_days"] = dry_streak
704
+ else:
705
+ dry_streak = 0
706
+ record["features"]["dry_spell_days"] = 0
707
+
708
+ # 3. Rolling 3 dan 7 hari (nilai hilang tidak diganti nol)
709
+ def rolling_value(index, source, key, window, operation):
710
+ if index + 1 < window:
711
+ return None
712
+ subset = records[index - window + 1:index + 1]
713
+ values = [_get(r, source, key) for r in subset]
714
+ if any(v is None for v in values):
715
+ return None
716
+ return sum(values) if operation == "sum" else sum(values) / len(values)
717
+
718
+ rolling_specs = [
719
+ # (source, key, operation, prefix)
720
+ ("raw", "precipitation_sum", "sum", "precipitation"),
721
+ ("raw", "et0_fao_evapotranspiration", "sum", "et0"),
722
+ ("feature", "water_balance_proxy_mm", "sum", "water_balance"),
723
+ ("raw", "growing_degree_days_base_0_limit_50", "sum", "gdd"),
724
+ ("raw", "temperature_2m_mean", "mean", "temperature_mean"),
725
+ ("raw", "vapour_pressure_deficit_max", "mean", "vpd"),
726
+ ("raw", "relative_humidity_2m_mean", "mean", "rh_mean_pct"),
727
+ ("feature", "leaf_wetness_fraction", "mean", "leaf_wetness"),
728
+ ]
729
+ for i, record in enumerate(records):
730
+ for window in (3, 7):
731
+ for source, key, operation, prefix in rolling_specs:
732
+ record["features"][f"{prefix}_{window}d_{operation}"] = rolling_value(
733
+ i, source, key, window, operation
734
+ )
735
+
736
+ # 4. Perubahan 1 hari
737
+ delta_specs = {
738
+ "temperature_mean_delta_1d": ("raw", "temperature_2m_mean"),
739
+ "vpd_delta_1d": ("raw", "vapour_pressure_deficit_max"),
740
+ "rh_mean_delta_1d": ("raw", "relative_humidity_2m_mean"),
741
+ "precipitation_delta_1d": ("raw", "precipitation_sum"),
742
+ "leaf_wetness_delta_1d": ("feature", "leaf_wetness_fraction"),
743
+ }
744
+ for i, record in enumerate(records):
745
+ for name, (source, key) in delta_specs.items():
746
+ value = None
747
+ if i > 0:
748
+ cur = _get(record, source, key)
749
+ prev = _get(records[i - 1], source, key)
750
+ if cur is not None and prev is not None:
751
+ value = cur - prev
752
+ record["features"][name] = value
753
+
754
+ # 5. Prakiraan relatif terhadap rata-rata historis 7 hari
755
+ anomaly_specs = {
756
+ "temperature_mean_delta_vs_history7d": ("raw", "temperature_2m_mean"),
757
+ "vpd_delta_vs_history7d": ("raw", "vapour_pressure_deficit_max"),
758
+ "rh_mean_delta_vs_history7d": ("raw", "relative_humidity_2m_mean"),
759
+ "precipitation_delta_vs_history7d": ("raw", "precipitation_sum"),
760
+ "et0_delta_vs_history7d": ("raw", "et0_fao_evapotranspiration"),
761
+ "leaf_wetness_delta_vs_history7d": ("feature", "leaf_wetness_fraction"),
762
+ }
763
+ historical_records = [r for r in records if r["period"] == "historical"]
764
+ baselines = {}
765
+ for name, (source, key) in anomaly_specs.items():
766
+ values = [v for v in (_get(r, source, key) for r in historical_records) if v is not None]
767
+ baselines[name] = sum(values) / len(values) if values else None
768
+
769
+ for record in records:
770
+ for name, (source, key) in anomaly_specs.items():
771
+ value = None
772
+ if record["period"] == "forecast":
773
+ cur = _get(record, source, key)
774
+ base = baselines[name]
775
+ if cur is not None and base is not None:
776
+ value = cur - base
777
+ record["features"][name] = value
778
+
779
+ # 6. Susun array fitur per periode
780
+ all_feature_names = sorted({k for r in records for k in r["features"]})
781
+ derived_blocks = {}
782
+ for period in periods:
783
+ period_records = [r for r in records if r["period"] == period]
784
+ derived_blocks[period] = {
785
+ "time": [r["date"] for r in period_records],
786
+ "features": {
787
+ name: [r["features"].get(name) for r in period_records]
788
+ for name in all_feature_names
789
+ },
790
+ }
791
+
792
+ # 7. Ringkasan periode
793
+ def mean_values(values):
794
+ values = [v for v in values if v is not None]
795
+ return sum(values) / len(values) if values else None
796
+
797
+ def total_values(values):
798
+ # Jangan tampilkan jumlah parsial sebagai total periode.
799
+ if not values or any(v is None for v in values):
800
+ return None
801
+ return sum(values)
802
+
803
+ def max_values(values):
804
+ return max([v for v in values if v is not None], default=None)
805
+
806
+ def min_values(values):
807
+ return min([v for v in values if v is not None], default=None)
808
+
809
+ def summarize_period(period):
810
+ subset = [r for r in records if r["period"] == period]
811
+
812
+ def raw_values(key):
813
+ return [r["raw"].get(key) for r in subset]
814
+
815
+ def feature_values(key):
816
+ return [r["features"].get(key) for r in subset]
817
+
818
+ return {
819
+ "days": len(subset),
820
+ "temperature_mean_c": mean_values(raw_values("temperature_2m_mean")),
821
+ "temperature_max_peak_c": max_values(raw_values("temperature_2m_max")),
822
+ "temperature_min_low_c": min_values(raw_values("temperature_2m_min")),
823
+ "temperature_range_mean_c": mean_values(feature_values("temperature_range_c")),
824
+ "precipitation_total_mm": total_values(raw_values("precipitation_sum")),
825
+ "precipitation_hours_total": total_values(raw_values("precipitation_hours")),
826
+ "et0_total_mm": total_values(raw_values("et0_fao_evapotranspiration")),
827
+ "water_balance_proxy_total_mm": total_values(feature_values("water_balance_proxy_mm")),
828
+ "gdd_total": total_values(raw_values("growing_degree_days_base_0_limit_50")),
829
+ "vpd_mean": mean_values(raw_values("vapour_pressure_deficit_max")),
830
+ "relative_humidity_mean_pct": mean_values(raw_values("relative_humidity_2m_mean")),
831
+ "leaf_wetness_mean_fraction": mean_values(feature_values("leaf_wetness_fraction")),
832
+ "humidity_wetness_interaction_mean": mean_values(feature_values("humidity_wetness_interaction")),
833
+ "longest_dry_spell_days": max_values(feature_values("dry_spell_days")),
834
+ "max_wind_speed": max_values(raw_values("wind_speed_10m_max")),
835
+ "max_wind_gust": max_values(raw_values("wind_gusts_10m_max")),
836
+ "precip_probability_mean_fraction": mean_values(feature_values("precip_probability_fraction")),
837
+ }
838
+
839
+ history_summary = summarize_period("historical")
840
+ forecast_summary = summarize_period("forecast")
841
+
842
+ comparison_keys = [
843
+ "temperature_mean_c", "precipitation_total_mm", "et0_total_mm",
844
+ "water_balance_proxy_total_mm", "gdd_total", "vpd_mean",
845
+ "relative_humidity_mean_pct", "leaf_wetness_mean_fraction",
846
+ ]
847
+ comparison = {}
848
+ for key in comparison_keys:
849
+ h, f = history_summary.get(key), forecast_summary.get(key)
850
+ comparison[key + "_forecast_minus_history"] = f - h if h is not None and f is not None else None
851
+
852
+ return {
853
+ "historical": derived_blocks["historical"],
854
+ "today": derived_blocks["today"],
855
+ "forecast": derived_blocks["forecast"],
856
+ "summary": {
857
+ "history_7d": history_summary,
858
+ "forecast_7d": forecast_summary,
859
+ "forecast_minus_history": comparison,
860
+ },
861
+ }
862
+
863
+
864
+ def normalize_weather_data(raw_data: Dict, lat: float, lon: float, client_date: str) -> Tuple[Optional[Dict], str]:
865
+ try:
866
+ timezone_str = raw_data.get("timezone", "UTC")
867
+ daily = raw_data["daily"]
868
+ dates = daily["time"]
869
+ location_date = dates[7] if len(dates) > 7 else client_date
870
+
871
+ def extract_block(start: int, end: Optional[int]) -> Dict:
872
+ time_slice = dates[start:end]
873
+ if not time_slice:
874
+ return {}
875
+ block = {"time": time_slice}
876
+ for var in WEATHER_VARIABLES:
877
+ block[var] = daily.get(var, [])[start:end]
878
+ return block
879
+
880
+ historical = extract_block(0, 7)
881
+ today = extract_block(7, 8)
882
+ forecast = extract_block(8, None)
883
+
884
+ units = raw_data.get("daily_units", {}) or {}
885
+ units_filtered = {k: v for k, v in units.items() if k != "time"}
886
+
887
+ normalized = {
888
+ "meta": {
889
+ "location": {
890
+ "latitude": raw_data.get("latitude", lat),
891
+ "longitude": raw_data.get("longitude", lon),
892
+ "timezone": timezone_str,
893
+ "elevation_m": raw_data.get("elevation"),
894
+ },
895
+ "client_date": client_date,
896
+ "location_date": location_date,
897
+ "analysis_period": {
898
+ "historical": f"{len(historical.get('time', []))} days",
899
+ "forecast": f"{len(forecast.get('time', []))} days",
900
+ "anchor_date": client_date,
901
+ },
902
+ },
903
+ "units": units_filtered,
904
+ "historical": historical,
905
+ "today": today,
906
+ "forecast": forecast,
907
+ }
908
+ normalized["derived_features"] = engineer_weather_features(normalized)
909
+ return normalized, ""
910
+
911
+ except Exception as e:
912
+ logger.error(f"Normalization error: {e}")
913
+ return None, "Failed to process weather data. Please try again later."
914
+
915
+
916
+ # ------------------------------------------------------------------
917
+ # LLM ENGINE
918
+ # ------------------------------------------------------------------
919
+ DERIVED_FEATURE_GUIDE = {
920
+ "temperature_range_c": "Daily maximum temperature minus minimum temperature.",
921
+ "dewpoint_depression_proxy_c": "Mean air temperature minus mean dewpoint.",
922
+ "water_balance_proxy_mm": "Precipitation minus reference ET0; not soil water balance.",
923
+ "precipitation_per_wet_hour_proxy":
924
+ "Daily precipitation divided by precipitation hours; not peak rainfall intensity.",
925
+ "humidity_wetness_interaction":
926
+ "Relative humidity fraction multiplied by leaf-wetness probability fraction; not disease probability.",
927
+ "wetness_duration_interaction":
928
+ "Leaf-wetness probability multiplied by precipitation hours divided by 24; "
929
+ "not measured leaf-wetness duration.",
930
+ "dry_spell_days":
931
+ "Consecutive days with precipitation at or below 0.1 mm; not a crop-specific drought threshold.",
932
+ "rolling_features":
933
+ "Trailing 3-day and 7-day sums or means. Windows span the continuous history, today, "
934
+ "and forecast timeline.",
935
+ "delta_vs_history7d":
936
+ "Forecast daily value minus the mean of available historical daily values over the previous 7 days.",
937
+ "forecast_minus_history":
938
+ "Difference between the 7-day forecast summary and the 7-day historical summary.",
939
+ "probability_fraction":
940
+ "Normalized to a 0-1 scale when the source unit is percent. Null means unavailable, not zero.",
941
+ }
942
+
943
+
944
+ def build_llm_payload(normalized_data: Dict, lat: float, lon: float, client_date: str,
945
+ crop: str = "", phenology: str = "", notes: str = "",
946
+ current_concern: str = "", has_image: bool = False) -> Dict:
947
+ meta = normalized_data.get("meta", {})
948
+ location = meta.get("location", {})
949
+ tz = location.get("timezone", "UTC")
950
+ return {
951
+ "location": {"latitude": lat, "longitude": lon, "timezone": tz},
952
+ "field_context": {
953
+ "crop_type": crop or "Tidak ditentukan",
954
+ "phenology_phase": phenology or "Tidak ditentukan",
955
+ "field_notes": notes or "Tidak ada catatan khusus",
956
+ "current_concern": current_concern or "Tidak ada concern khusus",
957
+ },
958
+ "user_image": {
959
+ "provided": has_image,
960
+ "note": "Foto lahan/tanaman dari pengguna terlampir (dikompresi, belum diverifikasi)."
961
+ if has_image else "Tidak ada foto.",
962
+ },
963
+ "analysis_period": {
964
+ "historical": meta.get("analysis_period", {}).get("historical", "7 days"),
965
+ "forecast": meta.get("analysis_period", {}).get("forecast", "7 days"),
966
+ "anchor_date": client_date,
967
+ "anchor_type": "client_time",
968
+ "location_date": meta.get("location_date", client_date),
969
+ "location_timezone": tz,
970
+ },
971
+ "units": normalized_data.get("units", {}),
972
+ "derived_features": normalized_data.get("derived_features", {}),
973
+ "derived_feature_guide": DERIVED_FEATURE_GUIDE,
974
+ "historical": {"daily": normalized_data.get("historical", {})},
975
+ "today": {"daily": normalized_data.get("today", {})},
976
+ "forecast": {"daily": normalized_data.get("forecast", {})},
977
+ }
978
+
979
+
980
+ def _build_messages(system_prompt: str, payload: Dict, image_data_url: Optional[str]) -> List[Dict]:
981
+ text = "Weather data for analysis:\n\n" + json.dumps(
982
+ payload, ensure_ascii=False, separators=(",", ":")
983
+ )
984
+ if image_data_url:
985
+ user_content: Any = [
986
+ {"type": "text", "text": text},
987
+ {"type": "image_url", "image_url": {"url": image_data_url}},
988
+ ]
989
+ else:
990
+ user_content = text
991
+ return [
992
+ {"role": "system", "content": system_prompt},
993
+ {"role": "user", "content": user_content},
994
+ ]
995
+
996
+
997
+ def _extract_json(content: str) -> Dict:
998
+ m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content)
999
+ if m:
1000
+ content = m.group(1)
1001
+ content = content.strip()
1002
+ start, end = content.find("{"), content.rfind("}")
1003
+ if start != -1 and end != -1:
1004
+ content = content[start:end + 1]
1005
+ return json.loads(content)
1006
+
1007
+
1008
+ def call_gemini(payload: Dict, image_data_url: Optional[str] = None) -> Tuple[Optional[Dict], str]:
1009
+ system_prompt = get_system_prompt()
1010
+ if not system_prompt:
1011
+ return None, "System prompt file is missing or empty. Please check prompts/system_prompt.txt"
1012
+
1013
+ use_image = bool(image_data_url)
1014
+ messages = _build_messages(system_prompt, payload, image_data_url)
1015
+ last_error_msg = "Unknown error"
1016
+ deadline = time.monotonic() + GEMINI_TOTAL_BUDGET_S
1017
+
1018
+ for attempt in range(GEMINI_MAX_ROUNDS):
1019
+ saw_rpm = False
1020
+
1021
+ for key in key_manager.get_rotation_order():
1022
+ if time.monotonic() > deadline:
1023
+ last_error_msg = "Batas waktu analisis terlampaui."
1024
+ break
1025
+ if not key_manager._is_available(key):
1026
+ continue
1027
+
1028
+ try:
1029
+ response = completion(
1030
+ model=GEMINI_MODEL,
1031
+ messages=messages,
1032
+ api_key=key,
1033
+ temperature=1.0,
1034
+ max_tokens=4000,
1035
+ timeout=GEMINI_TIMEOUT_IMAGE_S if use_image else GEMINI_TIMEOUT_S,
1036
+ )
1037
+ return _extract_json(response.choices[0].message.content), ""
1038
+
1039
+ except json.JSONDecodeError as e:
1040
+ logger.error(f"JSON decode error: {e}")
1041
+ return None, "Analysis could not be validated. Please try again. (Invalid JSON)"
1042
+
1043
+ except Exception as e:
1044
+ kind = classify_gemini_error(e)
1045
+ logger.warning(f"Key ...{key[-6:]} gagal ({kind}): {e}")
1046
+ if kind == "rpd":
1047
+ key_manager.mark_rpd_exhausted(key)
1048
+ last_error_msg = "Satu atau lebih API key mencapai limit harian."
1049
+ elif kind == "rpm":
1050
+ saw_rpm = True
1051
+ last_error_msg = "Key sedang sibuk (limit per menit)."
1052
+ else:
1053
+ last_error_msg = str(e)
1054
+ if use_image: # mungkin gambar yang ditolak -> coba tanpa gambar
1055
+ use_image = False
1056
+ messages = _build_messages(system_prompt, payload, None)
1057
+
1058
+ if not saw_rpm or time.monotonic() > deadline:
1059
+ break
1060
+ time.sleep(2 * (attempt + 1) + random.random()) # backoff + jitter
1061
+
1062
+ return None, (
1063
+ "Weather data was retrieved, but the analysis could not be generated. "
1064
+ f"Semua API key gagal atau kena limit. ({last_error_msg})"
1065
+ )
1066
+
1067
+
1068
+ def validate_llm_output(output: Any) -> Tuple[bool, str]:
1069
+ if not isinstance(output, dict):
1070
+ return False, "Analysis could not be validated. Invalid output format."
1071
+
1072
+ for field in ["summary", "historical", "forecast", "risks", "recommendations", "overall_confidence"]:
1073
+ if field not in output:
1074
+ return False, f"Analysis could not be validated. Missing field: {field}"
1075
+
1076
+ for name in ("historical", "forecast"):
1077
+ block = output.get(name)
1078
+ if not isinstance(block, dict):
1079
+ return False, f"Analysis could not be validated. Invalid {name} format."
1080
+ if "condition" not in block or "impact" not in block:
1081
+ return False, f"Analysis could not be validated. {name.title()} missing condition or impact."
1082
+
1083
+ if not isinstance(output["risks"], list):
1084
+ return False, "Analysis could not be validated. Invalid risks format."
1085
+ for i, risk in enumerate(output["risks"]):
1086
+ if not isinstance(risk, dict):
1087
+ return False, f"Analysis could not be validated. Invalid risk at index {i}."
1088
+ for rf in ["title", "description", "evidence", "period", "severity"]:
1089
+ if rf not in risk:
1090
+ return False, f"Analysis could not be validated. Risk {i} missing field: {rf}"
1091
+ if risk.get("severity") not in VALID_SEVERITY:
1092
+ return False, f"Analysis could not be validated. Invalid severity: {risk.get('severity')}"
1093
+
1094
+ if not isinstance(output["recommendations"], list):
1095
+ return False, "Analysis could not be validated. Invalid recommendations format."
1096
+ for i, rec in enumerate(output["recommendations"]):
1097
+ if not isinstance(rec, dict):
1098
+ return False, f"Analysis could not be validated. Invalid recommendation at index {i}."
1099
+ for rf in ["action", "reason", "priority"]:
1100
+ if rf not in rec:
1101
+ return False, f"Analysis could not be validated. Recommendation {i} missing field: {rf}"
1102
+ if rec.get("priority") not in VALID_PRIORITY:
1103
+ return False, f"Analysis could not be validated. Invalid priority: {rec.get('priority')}"
1104
+
1105
+ if output.get("overall_confidence") not in VALID_CONFIDENCE:
1106
+ return False, "Analysis could not be validated. Invalid overall_confidence."
1107
+
1108
+ return True, ""
1109
+
1110
+
1111
+ # ------------------------------------------------------------------
1112
+ # EXPORT
1113
+ # ------------------------------------------------------------------
1114
+ def cleanup_exports(max_age_s: int = EXPORT_MAX_AGE_S) -> None:
1115
+ """Hapus file export lama agar /tmp tidak penuh."""
1116
+ try:
1117
+ now = time.time()
1118
+ for name in os.listdir(EXPORT_DIR):
1119
+ if not name.startswith("weather_analysis_"):
1120
+ continue
1121
+ path = os.path.join(EXPORT_DIR, name)
1122
+ try:
1123
+ if now - os.path.getmtime(path) > max_age_s:
1124
+ os.remove(path)
1125
+ except OSError:
1126
+ pass
1127
+ except FileNotFoundError:
1128
+ pass
1129
+ except Exception as e:
1130
+ logger.warning(f"Export cleanup failed: {e}")
1131
+
1132
+
1133
+ def build_export_file(cache_entry: Optional[Dict]) -> Optional[str]:
1134
+ """
1135
+ Satu file TXT berisi input pengguna, data cuaca mentah, fitur turunan,
1136
+ dan hasil analisis Gemini, untuk dipakai sebagai context di LLM lain.
1137
+ Foto pengguna TIDAK disertakan (memang tidak disimpan).
1138
+ """
1139
+ if not cache_entry:
1140
+ return None
1141
+
1142
+ location = cache_entry.get("location", {}) or {}
1143
+ field_context = cache_entry.get("field_context", {}) or {}
1144
+ analysis = cache_entry.get("analysis", {}) or {}
1145
+
1146
+ lines: List[str] = [
1147
+ "=== EXPORT DATA ANALISIS CUACA ===",
1148
+ f"Dibuat pada : {datetime.now(timezone.utc).isoformat()}",
1149
+ "Tujuan : Context data untuk analisis lanjutan",
1150
+ "Sumber App : DigiTanist/tanam",
1151
+ "",
1152
+ "--- INPUT PENGGUNA ---",
1153
+ f"Latitude : {location.get('latitude')}",
1154
+ f"Longitude : {location.get('longitude')}",
1155
+ f"Sumber lokasi : {location.get('source') or '-'}",
1156
+ ]
1157
+ if location.get("accuracy_m"):
1158
+ lines.append(f"Akurasi GPS : {location.get('accuracy_m')} m")
1159
+ lines += [
1160
+ f"Tanggal analisis : {cache_entry.get('client_date')}",
1161
+ f"Timezone lokasi : {cache_entry.get('location_timezone')}",
1162
+ "",
1163
+ "Konteks Lahan:",
1164
+ f"- Jenis Tanaman : {field_context.get('crop_type') or '-'}",
1165
+ f"- Fase Fenologi : {field_context.get('phenology_phase') or '-'}",
1166
+ f"- Catatan Lapangan : {field_context.get('field_notes') or '-'}",
1167
+ f"- Current Concern : {field_context.get('current_concern') or '-'}",
1168
+ "",
1169
+ "--- DATA CUACA MENTAH (Open-Meteo, historical 7 hari + forecast 7 hari) ---",
1170
+ json.dumps(cache_entry.get("raw_weather", {}), ensure_ascii=False, indent=2),
1171
+ "",
1172
+ "--- FITUR CUACA TURUNAN ---",
1173
+ json.dumps(cache_entry.get("derived_features", {}), ensure_ascii=False, indent=2),
1174
+ "",
1175
+ "--- HASIL ANALISIS GEMINI AI ---",
1176
+ f"Ringkasan: {analysis.get('summary', '-')}",
1177
+ "",
1178
+ ]
1179
+
1180
+ hist = analysis.get("historical", {}) or {}
1181
+ lines += [
1182
+ "Historical (7 hari terakhir):",
1183
+ f" Kondisi : {hist.get('condition', '-')}",
1184
+ f" Dampak : {hist.get('impact', '-')}",
1185
+ "",
1186
+ ]
1187
+
1188
+ fcst = analysis.get("forecast", {}) or {}
1189
+ lines += [
1190
+ "Forecast (7 hari ke depan):",
1191
+ f" Kondisi : {fcst.get('condition', '-')}",
1192
+ f" Dampak : {fcst.get('impact', '-')}",
1193
+ "",
1194
+ ]
1195
+
1196
+ risks = [r for r in (analysis.get("risks") or []) if isinstance(r, dict)]
1197
+ lines.append(f"Risiko ({len(risks)}):")
1198
+ if risks:
1199
+ for i, r in enumerate(risks, 1):
1200
+ lines.append(f" {i}. {r.get('title', '-')} [severity: {r.get('severity', '-')}]")
1201
+ lines.append(f" Deskripsi : {r.get('description', '-')}")
1202
+ lines.append(f" Bukti : {r.get('evidence', '-')}")
1203
+ lines.append(f" Periode : {r.get('period', '-')}")
1204
+ else:
1205
+ lines.append(" Tidak ada risiko signifikan.")
1206
+ lines.append("")
1207
+
1208
+ recs = analysis.get("recommendations") or []
1209
+ lines.append(f"Rekomendasi ({len(recs)}):")
1210
+ if recs:
1211
+ for i, r in enumerate(recs, 1):
1212
+ if isinstance(r, str):
1213
+ lines.append(f" {i}. {r}")
1214
+ continue
1215
+ lines.append(f" {i}. {r.get('action', '-')} [priority: {r.get('priority', '-')}]")
1216
+ lines.append(f" Alasan : {r.get('reason', '-')}")
1217
+ else:
1218
+ lines.append(" Tidak ada tindakan khusus diperlukan.")
1219
+ lines += ["", f"Overall Confidence: {analysis.get('overall_confidence', '-')}"]
1220
+
1221
+ try:
1222
+ os.makedirs(EXPORT_DIR, exist_ok=True)
1223
+ cleanup_exports()
1224
+ date_tag = (cache_entry.get("client_date") or "unknown").replace("-", "")
1225
+ path = os.path.join(EXPORT_DIR, f"weather_analysis_{date_tag}_{uuid.uuid4().hex[:8]}.txt")
1226
+ with open(path, "w", encoding="utf-8") as f:
1227
+ f.write("\n".join(lines))
1228
+ return path
1229
+ except Exception as e:
1230
+ logger.error(f"Failed to build export file: {e}")
1231
+ return None
1232
+
1233
+
1234
+ # ------------------------------------------------------------------
1235
+ # MAIN HANDLERS
1236
+ # ------------------------------------------------------------------
1237
+ def init_app(composite_json: str):
1238
+ cache_entry = None
1239
+ browser_date = None
1240
+ browser_timezone = "UTC"
1241
+
1242
+ if composite_json and composite_json.strip() and composite_json != "{}":
1243
+ try:
1244
+ parsed = json.loads(composite_json)
1245
+ if isinstance(parsed, dict):
1246
+ if "browser_date" in parsed: # format baru (composite dari JS)
1247
+ cache_raw = parsed.get("cache", "{}")
1248
+ browser_date = parsed.get("browser_date")
1249
+ browser_timezone = parsed.get("browser_timezone", "UTC")
1250
+ if isinstance(cache_raw, str) and cache_raw.strip() and cache_raw != "{}":
1251
+ cache_parsed = json.loads(cache_raw)
1252
+ if isinstance(cache_parsed, dict) and "analysis" in cache_parsed:
1253
+ cache_entry = cache_parsed
1254
+ elif "analysis" in parsed: # format lama
1255
+ cache_entry = parsed
1256
+ except Exception:
1257
+ cache_entry = None
1258
+
1259
+ if not browser_date:
1260
+ browser_date = datetime.now().strftime("%Y-%m-%d")
1261
+ browser_timezone = "UTC"
1262
+
1263
+ # Bandingkan cache dengan TANGGAL BROWSER, bukan tanggal server
1264
+ today_cache = cache_entry if (cache_entry and cache_entry.get("client_date") == browser_date) else None
1265
+
1266
+ state = AppState(
1267
+ state=STATE_READY,
1268
+ client_date=browser_date,
1269
+ client_timezone=browser_timezone,
1270
+ browser_date=browser_date,
1271
+ browser_timezone=browser_timezone,
1272
+ cache=today_cache,
1273
+ )
1274
+
1275
+ if today_cache:
1276
+ state.state = STATE_LOCKED_TODAY
1277
+ location = today_cache.get("location") or {}
1278
+ state.location = location or None
1279
+ analysis = today_cache.get("analysis", {})
1280
+ return (
1281
+ state.to_dict(),
1282
+ location.get("latitude", 0), location.get("longitude", 0),
1283
+ gr.update(interactive=False), gr.update(interactive=False),
1284
+ format_status(STATE_LOCKED_TODAY, "Today's analysis already exists."),
1285
+ "",
1286
+ format_cache_info(today_cache),
1287
+ gr.update(visible=False),
1288
+ gr.update(visible=True),
1289
+ gr.update(visible=False),
1290
+ *render_all_tabs(analysis),
1291
+ gr.update(value=""),
1292
+ )
1293
+
1294
+ return (
1295
+ state.to_dict(), 0, 0,
1296
+ gr.update(interactive=True), gr.update(interactive=False),
1297
+ format_status(STATE_READY),
1298
+ "",
1299
+ "",
1300
+ gr.update(visible=True),
1301
+ gr.update(visible=False),
1302
+ gr.update(visible=False),
1303
+ *render_all_tabs({}),
1304
+ gr.update(value=""),
1305
+ )
1306
+
1307
+
1308
+ def handle_gps_result(gps_json: str, state_dict: Dict):
1309
+ state = AppState.from_dict(state_dict)
1310
+
1311
+ if not gps_json:
1312
+ return state.to_dict(), 0, 0, format_status(STATE_ERROR, "No location data received.")
1313
+
1314
+ try:
1315
+ data = json.loads(gps_json)
1316
+ except Exception:
1317
+ return state.to_dict(), 0, 0, format_status(STATE_ERROR, "Invalid location data.")
1318
+
1319
+ if "error" in data:
1320
+ return state.to_dict(), 0, 0, format_status(STATE_ERROR, data["error"])
1321
+
1322
+ lat = data.get("latitude")
1323
+ lon = data.get("longitude")
1324
+ source = data.get("source", "unknown")
1325
+ accuracy = data.get("accuracy_m")
1326
+
1327
+ valid, msg = validate_coordinates(lat, lon)
1328
+ if not valid:
1329
+ return state.to_dict(), 0, 0, format_status(STATE_ERROR, msg)
1330
+
1331
+ state.location = {"latitude": lat, "longitude": lon, "source": source, "accuracy_m": accuracy}
1332
+ state.state = STATE_LOCATION_READY
1333
+ return state.to_dict(), lat, lon, format_location_status(source, accuracy)
1334
+
1335
+
1336
+ def _analysis_pipeline(state: AppState, weather_json_str: str, lat, lon, crop, phenology,
1337
+ notes, current_concern, image_path):
1338
+ """Generator utama. `state` dimutasi di sini dan dikirim balik lewat yield."""
1339
+
1340
+ # --- Input kosong: tidak ada yang diubah ---
1341
+ if not weather_json_str:
1342
+ yield (state.to_dict(),) + tuple(gr.update() for _ in range(14))
1343
+ return
1344
+
1345
+ # --- Sudah ada analisis hari ini: jangan jalankan lagi ---
1346
+ client_date = state.browser_date or datetime.now().strftime("%Y-%m-%d")
1347
+ state.client_date = client_date
1348
+ existing = state.cache
1349
+ if existing and existing.get("client_date") == client_date:
1350
+ state.state = STATE_LOCKED_TODAY
1351
+ yield ui_outputs(
1352
+ state, lock_inputs=True,
1353
+ status=format_status(STATE_LOCKED_TODAY, "Today's analysis already exists."),
1354
+ cache_info=format_cache_info(existing),
1355
+ show_placeholder=False, show_view_btn=True,
1356
+ tabs=render_all_tabs(existing.get("analysis", {})),
1357
+ )
1358
+ return
1359
+
1360
+ if state.is_analyzing:
1361
+ yield (state.to_dict(),) + tuple(gr.update() for _ in range(14))
1362
+ return
1363
+
1364
+ # --- Parse & validasi data cuaca dari browser ---
1365
+ try:
1366
+ raw_weather = json.loads(weather_json_str)
1367
+ except Exception as exc:
1368
+ yield error_outputs(state, STATE_FETCHING_WEATHER, f"Failed to parse weather data: {exc}")
1369
+ return
1370
+
1371
+ if isinstance(raw_weather, dict) and "error" in raw_weather:
1372
+ err = raw_weather.get("reason") or raw_weather["error"]
1373
+ yield error_outputs(state, STATE_FETCHING_WEATHER, str(err))
1374
+ return
1375
+
1376
+ valid_weather = validate_weather_payload(raw_weather)
1377
+ if valid_weather:
1378
+ yield error_outputs(state, STATE_FETCHING_WEATHER, valid_weather)
1379
+ return
1380
+ raw_weather.pop("_client_ts", None)
1381
+
1382
+ valid_coords, coord_msg = validate_coordinates(lat, lon)
1383
+ if not valid_coords:
1384
+ yield error_outputs(state, STATE_FETCHING_WEATHER, coord_msg)
1385
+ return
1386
+
1387
+ prev = state.location or {}
1388
+ state.location = {
1389
+ "latitude": lat,
1390
+ "longitude": lon,
1391
+ "source": prev.get("source", "manual"),
1392
+ "accuracy_m": prev.get("accuracy_m"),
1393
+ }
1394
+ state.is_analyzing = True
1395
+
1396
+ # --- Step 2: proses data + foto ---
1397
+ yield progress_outputs(
1398
+ state, STATE_PROCESSING_DATA, "Processing weather data",
1399
+ "Splitting historical and forecast windows and computing derived indices.",
1400
+ )
1401
+
1402
+ normalized, norm_error = normalize_weather_data(raw_weather, lat, lon, client_date)
1403
+ if norm_error:
1404
+ yield error_outputs(state, STATE_PROCESSING_DATA, norm_error)
1405
+ return
1406
+
1407
+ image_data_url, image_err = compress_image_to_data_url(image_path)
1408
+ if image_err:
1409
+ logger.info(f"Lanjut tanpa foto: {image_err}")
1410
+
1411
+ # --- Step 3: Gemini ---
1412
+ yield progress_outputs(
1413
+ state, STATE_GENERATING_INSIGHT, "Generating weather insight",
1414
+ "Gemini is interpreting the data for risks and recommendations.",
1415
+ )
1416
+
1417
+ payload = build_llm_payload(
1418
+ normalized, lat, lon, client_date, crop, phenology, notes, current_concern,
1419
+ has_image=bool(image_data_url),
1420
+ )
1421
+ llm_output, gemini_error = call_gemini(payload, image_data_url)
1422
+ image_data_url = None # lepas dari memori
1423
+ payload = None
1424
+
1425
+ if gemini_error:
1426
+ yield error_outputs(state, STATE_GENERATING_INSIGHT, gemini_error)
1427
+ return
1428
+
1429
+ # --- Step 4: validasi ---
1430
+ yield progress_outputs(
1431
+ state, STATE_VALIDATING_OUTPUT, "Validating analysis",
1432
+ "Checking the response against the expected schema.",
1433
+ )
1434
+
1435
+ valid, val_error = validate_llm_output(llm_output)
1436
+ if not valid:
1437
+ yield error_outputs(state, STATE_VALIDATING_OUTPUT, val_error)
1438
+ return
1439
+
1440
+ # --- Selesai: simpan SATU entry cache (tanpa foto) ---
1441
+ cache_entry = {
1442
+ "location": state.location,
1443
+ "client_date": client_date,
1444
+ "location_date": normalized.get("meta", {}).get("location_date", client_date),
1445
+ "location_timezone": raw_weather.get("timezone", "UTC"),
1446
+ "client_timezone": state.client_timezone or "UTC",
1447
+ "field_context": {
1448
+ "crop_type": crop,
1449
+ "phenology_phase": phenology,
1450
+ "field_notes": notes,
1451
+ "current_concern": current_concern,
1452
+ },
1453
+ "raw_weather": raw_weather,
1454
+ "derived_features": normalized.get("derived_features", {}),
1455
+ "analysis": llm_output,
1456
+ "created_at": datetime.now(timezone.utc).isoformat(),
1457
+ }
1458
+ state.cache = cache_entry
1459
+ state.is_analyzing = False
1460
+ cache_json = json.dumps(cache_entry)
1461
+ tabs = render_all_tabs(llm_output)
1462
+
1463
+ state.state = STATE_COMPLETED
1464
+ yield ui_outputs(
1465
+ state, lock_inputs=True,
1466
+ status=format_status(STATE_COMPLETED), step=format_progress(STATE_COMPLETED),
1467
+ cache_info=format_cache_info(cache_entry),
1468
+ show_placeholder=False, show_view_btn=True, tabs=tabs, cache_json=cache_json,
1469
+ )
1470
+
1471
+ state.state = STATE_LOCKED_TODAY
1472
+ yield ui_outputs(
1473
+ state, lock_inputs=True,
1474
+ status=format_status(STATE_LOCKED_TODAY, "Today's analysis complete. Return tomorrow for a new analysis."),
1475
+ cache_info=format_cache_info(cache_entry),
1476
+ show_placeholder=False, show_view_btn=True, tabs=tabs, cache_json=cache_json,
1477
+ )
1478
+
1479
+
1480
+ def handle_weather_and_analyze(weather_json_str: str, lat: float, lon: float, crop: str,
1481
+ phenology: str, notes: str, current_concern: str,
1482
+ image_path: Optional[str], state_dict: Dict):
1483
+ state = AppState.from_dict(state_dict)
1484
+ try:
1485
+ yield from _analysis_pipeline(
1486
+ state, weather_json_str, lat, lon, crop, phenology, notes, current_concern, image_path
1487
+ )
1488
+ except Exception:
1489
+ logger.exception("Unexpected error in analysis pipeline")
1490
+ yield error_outputs(state, state.state, "Terjadi kesalahan tak terduga. Silakan coba lagi.")
1491
+
1492
+
1493
+ # ------------------------------------------------------------------
1494
+ # GRADIO UI (Gradio 6.x compatible)
1495
+ # ------------------------------------------------------------------
1496
+ THEME = gr.themes.Base(
1497
+ font=[gr.themes.GoogleFont("IBM Plex Sans"), "sans-serif"],
1498
+ font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"],
1499
+ ).set(
1500
+ body_background_fill="#0B1B22",
1501
+ body_background_fill_dark="#0B1B22",
1502
+ body_text_color="#E7F1F0",
1503
+ body_text_color_dark="#E7F1F0",
1504
+ body_text_color_subdued="#7E9CA3",
1505
+ body_text_color_subdued_dark="#7E9CA3",
1506
+ background_fill_primary="#122631",
1507
+ background_fill_primary_dark="#122631",
1508
+ background_fill_secondary="#0F2229",
1509
+ background_fill_secondary_dark="#0F2229",
1510
+ border_color_primary="#23414F",
1511
+ border_color_primary_dark="#23414F",
1512
+ block_background_fill="#122631",
1513
+ block_background_fill_dark="#122631",
1514
+ block_border_color="#23414F",
1515
+ block_border_color_dark="#23414F",
1516
+ block_label_text_color="#7E9CA3",
1517
+ block_label_text_color_dark="#7E9CA3",
1518
+ block_label_background_fill="#122631",
1519
+ block_label_background_fill_dark="#122631",
1520
+ block_title_text_color="#E7F1F0",
1521
+ block_title_text_color_dark="#E7F1F0",
1522
+ panel_background_fill="#0F2229",
1523
+ panel_background_fill_dark="#0F2229",
1524
+ panel_border_color="#23414F",
1525
+ panel_border_color_dark="#23414F",
1526
+ input_background_fill="#0F2229",
1527
+ input_background_fill_dark="#0F2229",
1528
+ input_border_color="#23414F",
1529
+ input_border_color_dark="#23414F",
1530
+ input_border_color_focus="#E8A33D",
1531
+ input_border_color_focus_dark="#E8A33D",
1532
+ button_primary_background_fill="#E8A33D",
1533
+ button_primary_background_fill_dark="#E8A33D",
1534
+ button_primary_background_fill_hover="#F2B457",
1535
+ button_primary_background_fill_hover_dark="#F2B457",
1536
+ button_primary_text_color="#0B1B22",
1537
+ button_primary_text_color_dark="#0B1B22",
1538
+ button_primary_border_color="#E8A33D",
1539
+ button_primary_border_color_dark="#E8A33D",
1540
+ button_secondary_background_fill="#16303D",
1541
+ button_secondary_background_fill_dark="#16303D",
1542
+ button_secondary_background_fill_hover="#1B3945",
1543
+ button_secondary_background_fill_hover_dark="#1B3945",
1544
+ button_secondary_text_color="#E7F1F0",
1545
+ button_secondary_text_color_dark="#E7F1F0",
1546
+ button_secondary_border_color="#23414F",
1547
+ button_secondary_border_color_dark="#23414F",
1548
+ error_background_fill="#2A1714",
1549
+ error_background_fill_dark="#2A1714",
1550
+ error_border_color="#E2604F",
1551
+ error_border_color_dark="#E2604F",
1552
+ )
1553
+
1554
+ # ------------------------------------------------------------------
1555
+ # EXTERNAL ASSETS
1556
+ # ------------------------------------------------------------------
1557
+ CSS = load_text("assets/styles.css")
1558
+ SPONSORS_RAW = load_json("assets/sponsors.json")
1559
+ SPONSOR_HTML = render_sponsors_html(SPONSORS_RAW.get("sponsors", []))
1560
+
1561
+ HEADER_HTML = """
1562
+ <div class="hero-banner">
1563
+ <div class="app-header">
1564
+ <div class="eyebrow">Field Telemetry &middot; Weather Intelligence</div>
1565
+ <h1>DigiTanist/tanam</h1>
1566
+ <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>
1567
+ </div>
1568
+
1569
+ <div class="farmer-counter">
1570
+ <span class="farmer-counter-badge">
1571
+ <span class="farmer-counter-dot"></span>
1572
+ <span><strong id="farmer-count">—</strong> petani sudah menganalisis hari ini</span>
1573
+ </span>
1574
+ </div>
1575
+ </div>
1576
+ """
1577
+
1578
+ FOOTER_HTML = """
1579
+ <div class="app-footer">DATA &middot; OPEN-METEO&nbsp;&nbsp;|&nbsp;&nbsp;ANALYSIS &middot; GEMINI&nbsp;&nbsp;|&nbsp;&nbsp;ONE READ PER DAY</div>
1580
+ """
1581
+
1582
+ IMAGE_NOTE_HTML = (
1583
+ '<div style="font-size:12px;color:#7E9CA3;margin:-4px 2px 8px;line-height:1.4;">'
1584
+ 'Opsional. Foto dikompres lalu dikirim ke Gemini hanya untuk analisis ini, '
1585
+ 'tidak disimpan permanen dan tidak masuk file export.'
1586
+ '</div>'
1587
+ )
1588
+
1589
+ GA4_HEAD = """
1590
+ <!-- Google tag (gtag.js) -->
1591
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-VWC31N398K"></script>
1592
+ <script>
1593
+ window.dataLayer = window.dataLayer || [];
1594
+ function gtag(){dataLayer.push(arguments);}
1595
+ gtag('js', new Date());
1596
+ gtag('config', 'G-VWC31N398K');
1597
+ </script>
1598
+
1599
+ <script>
1600
+ function getTodayFarmerCount() {
1601
+ // ====================== PENGATURAN ANGKA ======================
1602
+ const BASE_MIN = 220;
1603
+ const BASE_MAX = 380;
1604
+ const INCREASE_PER_10_MIN = 2; // Jumlah penambahan setiap 10 menit (bisa disesuaikan)
1605
+ // ==============================================================
1606
+
1607
+ const now = new Date();
1608
+
1609
+ const today = now.getFullYear() + '-' +
1610
+ String(now.getMonth() + 1).padStart(2, '0') + '-' +
1611
+ String(now.getDate()).padStart(2, '0');
1612
+
1613
+ let seed = 0;
1614
+ for (let i = 0; i < today.length; i++) {
1615
+ seed = (seed * 31 + today.charCodeAt(i)) & 0xffff;
1616
+ }
1617
+
1618
+ const base = BASE_MIN + (seed % (BASE_MAX - BASE_MIN + 1));
1619
+ const totalMinutes = (now.getHours() * 60) + now.getMinutes();
1620
+ const blocksOf10Min = Math.floor(totalMinutes / 10);
1621
+ const extra = blocksOf10Min * INCREASE_PER_10_MIN;
1622
+
1623
+ return base + extra;
1624
+ }
1625
+
1626
+ function animateCount(el, target, duration = 3300) {
1627
+ const start = Math.max(0, target - Math.floor(target * 0.15)); // mulai dari ~85%
1628
+ const startTime = performance.now();
1629
+ function tick(now) {
1630
+ const progress = Math.min((now - startTime) / duration, 1);
1631
+ const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
1632
+ el.innerText = Math.round(start + (target - start) * eased).toLocaleString("id-ID");
1633
+ if (progress < 1) requestAnimationFrame(tick);
1634
+ }
1635
+ requestAnimationFrame(tick);
1636
+ }
1637
+
1638
+ function updateFarmerCount() {
1639
+ const el = document.getElementById("farmer-count");
1640
+ if (el) {
1641
+ animateCount(el, getTodayFarmerCount());
1642
+ return true;
1643
+ }
1644
+ return false;
1645
+ }
1646
+
1647
+ // Coba terus sampai elemen muncul (maksimal 10 detik)
1648
+ function tryUpdateCounter(attempts = 0) {
1649
+ if (updateFarmerCount() || attempts > 20) return;
1650
+ setTimeout(() => tryUpdateCounter(attempts + 1), 500);
1651
+ }
1652
+
1653
+ tryUpdateCounter();
1654
+ setInterval(updateFarmerCount, 5 * 60 * 1000);
1655
+ </script>
1656
+ """
1657
+
1658
+ LOAD_JS = """() => {
1659
+ let cache = '{}';
1660
+ try {
1661
+ cache = localStorage.getItem('space_weather_cache') || '{}';
1662
+ } catch (e) {
1663
+ cache = '{}';
1664
+ }
1665
+ const now = new Date();
1666
+ const browserDate = now.getFullYear() + '-' +
1667
+ String(now.getMonth() + 1).padStart(2, '0') + '-' +
1668
+ String(now.getDate()).padStart(2, '0');
1669
+ const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
1670
+ return JSON.stringify({
1671
+ cache: cache,
1672
+ browser_date: browserDate,
1673
+ browser_timezone: browserTz
1674
+ });
1675
+ }"""
1676
+
1677
+ GPS_JS = """
1678
+ async () => {
1679
+ const fail = () => JSON.stringify({
1680
+ error: "Unable to determine your location. Please enter coordinates manually."
1681
+ });
1682
+ const fromIp = async () => {
1683
+ try {
1684
+ const resp = await fetch('https://ipwho.is/');
1685
+ const data = await resp.json();
1686
+ if (data.success) {
1687
+ return JSON.stringify({
1688
+ latitude: data.latitude, longitude: data.longitude,
1689
+ source: "ip", accuracy_m: null
1690
+ });
1691
+ }
1692
+ } catch (e) {}
1693
+ return fail();
1694
+ };
1695
+ return new Promise((resolve) => {
1696
+ if (!navigator.geolocation) {
1697
+ fromIp().then(resolve);
1698
+ return;
1699
+ }
1700
+ navigator.geolocation.getCurrentPosition(
1701
+ (position) => resolve(JSON.stringify({
1702
+ latitude: position.coords.latitude,
1703
+ longitude: position.coords.longitude,
1704
+ source: "gps",
1705
+ accuracy_m: position.coords.accuracy
1706
+ })),
1707
+ () => { fromIp().then(resolve); },
1708
+ {timeout: 10000, maximumAge: 60000}
1709
+ );
1710
+ });
1711
+ }
1712
+ """
1713
+
1714
+ # Daftar variabel disuntik dari WEATHER_VARIABLES supaya JS dan Python selalu sinkron.
1715
+ # `_client_ts` membuat setiap klik Analyze menghasilkan string unik, sehingga event
1716
+ # `change` pada textbox tersembunyi selalu terpicu (juga saat mencoba ulang setelah error).
1717
+ ANALYZE_JS = """async (lat, lon) => {
1718
+ if (typeof gtag === 'function') {
1719
+ gtag('event', 'analyze_click', {
1720
+ 'event_category': 'engagement',
1721
+ 'latitude': lat,
1722
+ 'longitude': lon
1723
+ });
1724
+ }
1725
+ const stamp = (obj) => JSON.stringify(Object.assign({}, obj, {_client_ts: Date.now()}));
1726
+
1727
+ if (lat === null || lon === null || isNaN(lat) || isNaN(lon)) {
1728
+ return stamp({ error: "Invalid coordinates. Please enter a valid latitude and longitude." });
1729
+ }
1730
+
1731
+ const variables = __VARS__.join(",");
1732
+ const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&daily=${variables}&timezone=auto&past_days=7&forecast_days=7`;
1733
+ try {
1734
+ const resp = await fetch(url);
1735
+ if (!resp.ok) {
1736
+ return stamp({ error: `Weather data unavailable. Please try again later. (HTTP ${resp.status})` });
1737
+ }
1738
+ const data = await resp.json();
1739
+ return stamp(data);
1740
+ } catch (e) {
1741
+ return stamp({ error: "Weather data unavailable. Network error. Please try again later." });
1742
+ }
1743
+ }""".replace("__VARS__", json.dumps(WEATHER_VARIABLES))
1744
+
1745
+ VIEW_INSIGHT_JS = """() => {
1746
+ if (typeof gtag === 'function') {
1747
+ gtag('event', 'view_insight_click', {'event_category': 'engagement'});
1748
+ }
1749
+ }"""
1750
+
1751
+ SAVE_CACHE_JS = """(data) => {
1752
+ if (data && data !== '{}' && data !== '') {
1753
+ try {
1754
+ localStorage.setItem('space_weather_cache', data); // selalu timpa
1755
+ } catch (e) {
1756
+ console.error('Failed to save cache to localStorage:', e);
1757
+ }
1758
+ }
1759
+ return [];
1760
+ }"""
1761
+
1762
+
1763
+ def show_insight_view(state_dict):
1764
+ state = AppState.from_dict(state_dict)
1765
+ has_cache = bool(state.cache)
1766
+ modal_html = build_recommendation_modal(state.cache, skip_url=SHOPEE_LINK) if has_cache else ""
1767
+ # Tombol download ditampilkan dulu tanpa file; href diisi di langkah berikutnya (.then)
1768
+ # agar elemen sudah ter-mount dan klik pertama langsung bekerja.
1769
+ return (
1770
+ gr.update(visible=False),
1771
+ gr.update(visible=True),
1772
+ gr.update(visible=has_cache, value=None),
1773
+ modal_html,
1774
+ )
1775
+
1776
+
1777
+ def prepare_download_file(state_dict):
1778
+ state = AppState.from_dict(state_dict)
1779
+ export_path = build_export_file(state.cache)
1780
+ return gr.update(value=export_path, visible=bool(export_path))
1781
+
1782
+
1783
+ def clear_image_after_success(state_dict):
1784
+ """Lepas referensi foto setelah analisis sukses; biarkan jika gagal agar bisa dicoba lagi."""
1785
+ if AppState.from_dict(state_dict).cache:
1786
+ return gr.update(value=None)
1787
+ return gr.update()
1788
+
1789
+
1790
+ with gr.Blocks(title="Space Weather", head=GA4_HEAD, delete_cache=(600, 600)) as demo:
1791
+
1792
+ gr.HTML(HEADER_HTML)
1793
+
1794
+ app_state = gr.State({})
1795
+ gps_data = gr.Textbox(visible=False, elem_id="gps_data_input")
1796
+ cache_in = gr.Textbox(visible=False, elem_id="browser_cache_in")
1797
+ cache_out = gr.Textbox(visible=False, elem_id="browser_cache_out")
1798
+ weather_json = gr.Textbox(visible=False, elem_id="weather_json_input")
1799
+
1800
+ with gr.Row(elem_classes=["main-row"]):
1801
+ with gr.Column(scale=1, min_width=320, elem_classes=["rail-col"]):
1802
+ with gr.Column(elem_classes=["console-panel"]):
1803
+ gr.HTML('<div class="panel-eyebrow">Location</div>')
1804
+
1805
+ lat_input = gr.Number(label="Latitude", precision=6, value=0, info="-90 to 90")
1806
+ lon_input = gr.Number(label="Longitude", precision=6, value=0, info="-180 to 180")
1807
+
1808
+ crop_input = gr.Textbox(
1809
+ label="Jenis Tanaman",
1810
+ placeholder="Contoh: Padi, Jagung, Cabai",
1811
+ lines=1,
1812
+ )
1813
+ phenology_input = gr.Textbox(
1814
+ label="Fase Fenologi",
1815
+ placeholder="Contoh: Vegetatif, Pembungaan, Pematangan",
1816
+ lines=1,
1817
+ )
1818
+ notes_input = gr.Textbox(
1819
+ label="Catatan Lapangan",
1820
+ placeholder="Contoh: Ada genangan air, gejala serangan hama ringan",
1821
+ lines=2,
1822
+ )
1823
+ current_concern_input = gr.Textbox(
1824
+ label="Current Concern",
1825
+ placeholder="Contoh: Khawatir kekurangan air atau risiko penyakit jamur",
1826
+ info="Opsional. Masukkan kondisi atau pertanyaan yang ingin diperiksa berdasarkan cuaca.",
1827
+ lines=2,
1828
+ )
1829
+
1830
+ # Foto opsional: filepath agar bisa didecode hemat memori (PIL draft)
1831
+ image_input = gr.Image(
1832
+ label="Foto Lahan/Tanaman (Opsional)",
1833
+ type="filepath",
1834
+ sources=["upload"],
1835
+ height=200,
1836
+ )
1837
+ gr.HTML(IMAGE_NOTE_HTML)
1838
+
1839
+ with gr.Row(elem_classes=["btn-row"]):
1840
+ get_loc_btn = gr.Button("Ambil lokasi dari GPS perangkat", variant="secondary", size="sm")
1841
+ analyze_btn = gr.Button("Analyze", variant="primary", size="sm", interactive=False)
1842
+
1843
+ with gr.Column():
1844
+ status_html = gr.HTML("")
1845
+ step_html = gr.HTML("")
1846
+ cache_html = gr.HTML("")
1847
+
1848
+ with gr.Column(scale=2, elem_classes=["content-col"]):
1849
+ initial_placeholder = gr.HTML(render_placeholder("Initializing", "Loading today's status..."))
1850
+ view_insight_btn = gr.Button("View Insight", variant="primary", visible=False, size="lg")
1851
+
1852
+ with gr.Column(visible=False) as tabs_container:
1853
+ with gr.Tabs():
1854
+ with gr.TabItem("Summary"):
1855
+ summary_html = gr.HTML()
1856
+ with gr.TabItem("Historical"):
1857
+ historical_html = gr.HTML()
1858
+ with gr.TabItem("Forecast"):
1859
+ forecast_html = gr.HTML()
1860
+ with gr.TabItem("Risks"):
1861
+ risks_html = gr.HTML()
1862
+ with gr.TabItem("Recommendations"):
1863
+ recommendations_html = gr.HTML()
1864
+
1865
+ download_data_btn = gr.DownloadButton(
1866
+ "Download Data Analisis (TXT)",
1867
+ visible=False,
1868
+ size="md",
1869
+ variant="secondary",
1870
+ elem_classes=["download-data-btn"],
1871
+ )
1872
+
1873
+ gr.HTML('<div style="height: 16px;"></div>')
1874
+ with gr.Column(elem_classes=["console-panel"]):
1875
+ gr.HTML('<div class="panel-eyebrow" style="padding: 14px 14px 6px;">Supported By</div>')
1876
+ gr.HTML(SPONSOR_HTML)
1877
+
1878
+ modal_output = gr.HTML("", elem_id="modal-output")
1879
+ gr.HTML(FOOTER_HTML)
1880
+
1881
+ # 1. Page load: baca localStorage + waktu browser
1882
+ demo.load(fn=None, js=LOAD_JS, outputs=cache_in)
1883
+
1884
+ # 2. Inisialisasi app dari cache browser
1885
+ cache_in.change(
1886
+ fn=init_app,
1887
+ inputs=[cache_in],
1888
+ outputs=[
1889
+ app_state, lat_input, lon_input, get_loc_btn, analyze_btn,
1890
+ status_html, step_html, cache_html,
1891
+ initial_placeholder, view_insight_btn, tabs_container,
1892
+ summary_html, historical_html, forecast_html, risks_html, recommendations_html,
1893
+ cache_out,
1894
+ ],
1895
+ )
1896
+
1897
+ # 3. Lokasi: GPS -> fallback IP
1898
+ get_loc_btn.click(fn=None, js=GPS_JS, outputs=gps_data)
1899
+ gps_data.change(
1900
+ fn=handle_gps_result,
1901
+ inputs=[gps_data, app_state],
1902
+ outputs=[app_state, lat_input, lon_input, status_html],
1903
+ )
1904
+
1905
+ # 4. Analyze: browser fetch Open-Meteo -> textbox tersembunyi -> server
1906
+ analyze_btn.click(fn=None, js=ANALYZE_JS, inputs=[lat_input, lon_input], outputs=[weather_json])
1907
+
1908
+ analysis_event = weather_json.change(
1909
+ fn=handle_weather_and_analyze,
1910
+ inputs=[
1911
+ weather_json, lat_input, lon_input, crop_input, phenology_input,
1912
+ notes_input, current_concern_input, image_input, app_state,
1913
+ ],
1914
+ outputs=[
1915
+ app_state, get_loc_btn, analyze_btn,
1916
+ status_html, step_html, cache_html,
1917
+ initial_placeholder, view_insight_btn, tabs_container,
1918
+ summary_html, historical_html, forecast_html, risks_html, recommendations_html,
1919
+ cache_out,
1920
+ ],
1921
+ concurrency_limit=CONCURRENCY_LIMIT,
1922
+ concurrency_id="gemini_analysis",
1923
+ )
1924
+ analysis_event.then(
1925
+ fn=clear_image_after_success,
1926
+ inputs=[app_state],
1927
+ outputs=[image_input],
1928
+ show_progress="hidden",
1929
+ )
1930
+
1931
+ lat_input.change(fn=validate_coordinates_ui, inputs=[lat_input, lon_input, app_state], outputs=[analyze_btn])
1932
+ lon_input.change(fn=validate_coordinates_ui, inputs=[lat_input, lon_input, app_state], outputs=[analyze_btn])
1933
+
1934
+ # 5. View Insight (+ modal rekomendasi + file download)
1935
+ view_insight_btn.click(
1936
+ fn=show_insight_view,
1937
+ inputs=[app_state],
1938
+ js=VIEW_INSIGHT_JS,
1939
+ outputs=[view_insight_btn, tabs_container, download_data_btn, modal_output],
1940
+ ).then(
1941
+ fn=prepare_download_file,
1942
+ inputs=[app_state],
1943
+ outputs=[download_data_btn],
1944
+ )
1945
+
1946
+ # 6. Simpan cache ke localStorage (timpa data lama)
1947
+ cache_out.change(fn=None, js=SAVE_CACHE_JS, inputs=[cache_out])
1948
+
1949
+
1950
+ demo.queue(default_concurrency_limit=10, max_size=100)
1951
+
1952
+ if __name__ == "__main__":
1953
+ demo.launch(
1954
+ server_name="0.0.0.0",
1955
+ server_port=7860,
1956
+ theme=THEME,
1957
+ css=CSS,
1958
+ allowed_paths=["assets"],
1959
+ max_threads=60,
1960
+ max_file_size=MAX_UPLOAD_SIZE,
1961
+ )
image_utils.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import io
3
+ import logging
4
+ from typing import Optional, Tuple
5
+
6
+ from PIL import Image, ImageOps
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ MAX_SIDE = 768 # sisi terpanjang setelah resize
11
+ JPEG_QUALITIES = (72, 60, 50)
12
+ MAX_OUTPUT_BYTES = 200_000
13
+ MAX_PIXELS = 40_000_000 # tolak gambar raksasa (decompression bomb)
14
+ Image.MAX_IMAGE_PIXELS = MAX_PIXELS
15
+
16
+
17
+ def compress_image_to_data_url(path: Optional[str]) -> Tuple[Optional[str], str]:
18
+ """Return (data_url, error). Semua di memori; tidak ada file baru ditulis."""
19
+ if not path:
20
+ return None, ""
21
+ try:
22
+ with Image.open(path) as src:
23
+ if src.width * src.height > MAX_PIXELS:
24
+ return None, "Resolusi foto terlalu besar."
25
+ src.draft("RGB", (MAX_SIDE * 2, MAX_SIDE * 2)) # percepat decode JPEG
26
+ img = ImageOps.exif_transpose(src) # perbaiki rotasi HP
27
+ img = img.convert("RGB")
28
+ img.thumbnail((MAX_SIDE, MAX_SIDE), Image.Resampling.LANCZOS)
29
+
30
+ data = b""
31
+ for q in JPEG_QUALITIES:
32
+ buf = io.BytesIO()
33
+ img.save(buf, "JPEG", quality=q, optimize=True) # EXIF/GPS tidak ikut
34
+ data = buf.getvalue()
35
+ if len(data) <= MAX_OUTPUT_BYTES:
36
+ break
37
+
38
+ b64 = base64.b64encode(data).decode("ascii")
39
+ return f"data:image/jpeg;base64,{b64}", ""
40
+ except Exception as e:
41
+ logger.warning(f"Image compress failed: {e}")
42
+ return None, "Foto tidak dapat diproses."