FajarHidaa commited on
Commit
2652851
·
verified ·
1 Parent(s): 5b8c755

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -2046
app.py DELETED
@@ -1,2046 +0,0 @@
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 start_download_loading():
1983
- # Langkah 1 (instan): kunci tombol & ubah label jadi indikator
1984
- # loading, supaya user tahu proses berjalan dan tidak klik
1985
- # berkali-kali sebelum file selesai dirangkai.
1986
- return gr.update(interactive=False, label="⏳ Menyiapkan file...")
1987
-
1988
- def handle_download_click(state_dict):
1989
- # PENTING: gr.DownloadButton hanya memicu auto-download untuk
1990
- # update value yang terjadi di DALAM event klik yang sama dengan
1991
- # yang men-trigger-nya. Kalau proses "loading" dan "generate file"
1992
- # dipecah jadi dua event terpisah (mis. via .then()), auto-download
1993
- # hanya terpicu di klik BERIKUTNYA (karena value baru baru tersedia
1994
- # setelah event klik pertama selesai) — itulah sebabnya butuh 2x
1995
- # klik. Solusinya: satu fungsi generator, dua kali yield, tetap
1996
- # dalam SATU event klik.
1997
-
1998
- # Yield 1 (instan): kunci tombol + ubah label jadi indikator
1999
- # loading, supaya user tahu proses berjalan dan tidak klik lagi.
2000
- yield gr.update(interactive=False, label="⏳ Menyiapkan file...")
2001
-
2002
- # Yield 2: file sudah jadi -> set value supaya auto-download
2003
- # terpicu, lalu kembalikan tombol ke kondisi normal.
2004
- state = AppState.from_dict(state_dict)
2005
- export_path = build_export_file(state.cache)
2006
- if export_path:
2007
- label = "⬇ Download Data Analisis (TXT)"
2008
- else:
2009
- label = "⚠ Gagal membuat file, coba lagi"
2010
- yield gr.update(value=export_path, interactive=True, label=label)
2011
-
2012
- download_data_btn.click(
2013
- fn=handle_download_click,
2014
- inputs=[app_state],
2015
- outputs=[download_data_btn]
2016
- )
2017
-
2018
-
2019
-
2020
-
2021
- # 4. When cache_out changes, save to localStorage (overwrite old data)
2022
- cache_out.change(
2023
- fn=None,
2024
- js="""(data) => {
2025
- if (data && data !== '{}' && data !== '') {
2026
- try {
2027
- // Always overwrite — never accumulate
2028
- localStorage.setItem('space_weather_cache', data);
2029
- } catch (e) {
2030
- console.error('Failed to save cache to localStorage:', e);
2031
- }
2032
- }
2033
- return [];
2034
- }""",
2035
- inputs=[cache_out]
2036
- )
2037
-
2038
-
2039
- if __name__ == "__main__":
2040
- demo.launch(
2041
- server_name="0.0.0.0",
2042
- server_port=7860,
2043
- theme=THEME,
2044
- css=CSS,
2045
- allowed_paths=["assets"]
2046
- )