Spaces:
Sleeping
Sleeping
| """ | |
| Space Weather - Weather Intelligence Application | |
| Built with Gradio, LiteLLM, Open-Meteo, and Gemini | |
| Compatible with Gradio 6.x | |
| Browser-cache version: SINGLE ENTRY (overwrite old data) | |
| """ | |
| import os | |
| import json | |
| import re | |
| import html | |
| import uuid | |
| from datetime import datetime, timezone | |
| from typing import Dict, List, Optional, Tuple, Any | |
| from dataclasses import dataclass | |
| import logging | |
| import gradio as gr | |
| import requests | |
| from litellm import completion | |
| from gemini_key_manager import GeminiKeyManager, classify_gemini_error | |
| # Suppress Gradio 6.x internal warnings | |
| import warnings | |
| warnings.filterwarnings("ignore", category=RuntimeWarning, message="coroutine.*was never awaited") | |
| warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*event loop.*") | |
| # ------------------------------------------------------------------ | |
| # FILE LOADERS | |
| # ------------------------------------------------------------------ | |
| def load_text(path: str) -> str: | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| return f.read() | |
| except Exception as e: | |
| logger.error(f"Failed to load {path}: {e}") | |
| return "" | |
| def load_json(path: str) -> dict: | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| return json.load(f) | |
| except Exception as e: | |
| logger.error(f"Failed to load {path}: {e}") | |
| return {} | |
| # ------------------------------------------------------------------ | |
| # CONFIGURATION | |
| # ------------------------------------------------------------------ | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" | |
| # Directory to write user-downloadable JSON export files | |
| EXPORT_DIR = "/tmp/weather_exports" | |
| # Definisikan link Shopee Affiliate di sini agar mudah diubah | |
| SHOPEE_LINK = "https://shopee.co.id/" | |
| # Gemini API key rotation manager (baca GEMINI_API_KEY_1..N dari secrets) | |
| key_manager = GeminiKeyManager() # <-- BARU | |
| # ------------------------------------------------------------------ | |
| # STATE MACHINE CONSTANTS | |
| # ------------------------------------------------------------------ | |
| STATE_READY = "READY" | |
| STATE_LOCATING = "LOCATING" | |
| STATE_LOCATION_READY = "LOCATION_READY" | |
| STATE_ANALYZING = "ANALYZING" | |
| STATE_FETCHING_WEATHER = "FETCHING_WEATHER" | |
| STATE_PROCESSING_DATA = "PROCESSING_DATA" | |
| STATE_GENERATING_INSIGHT = "GENERATING_INSIGHT" | |
| STATE_VALIDATING_OUTPUT = "VALIDATING_OUTPUT" | |
| STATE_COMPLETED = "COMPLETED" | |
| STATE_ERROR = "ERROR" | |
| STATE_LOCKED_TODAY = "LOCKED_TODAY" | |
| PROGRESS_STEPS = { | |
| STATE_FETCHING_WEATHER: ("Step 1/5", "Getting weather data..."), | |
| STATE_PROCESSING_DATA: ("Step 2/5", "Processing weather data..."), | |
| STATE_GENERATING_INSIGHT: ("Step 3/5", "Generating weather insight..."), | |
| STATE_VALIDATING_OUTPUT: ("Step 4/5", "Validating analysis..."), | |
| STATE_COMPLETED: ("Step 5/5", "Analysis complete"), | |
| } | |
| STEP_ORDER = [ | |
| STATE_FETCHING_WEATHER, | |
| STATE_PROCESSING_DATA, | |
| STATE_GENERATING_INSIGHT, | |
| STATE_VALIDATING_OUTPUT, | |
| STATE_COMPLETED, | |
| ] | |
| STEP_LABELS = ["Weather Data", "Processing", "Insight", "Validation", "Complete"] | |
| STATUS_VARIANTS = { | |
| STATE_READY: ("neutral", "Ready"), | |
| STATE_LOCATION_READY: ("info", "Location ready"), | |
| STATE_ANALYZING: ("active", "Analyzing"), | |
| STATE_ERROR: ("error", "Error"), | |
| STATE_LOCKED_TODAY: ("info", "Locked"), | |
| STATE_COMPLETED: ("success", "Complete"), | |
| } | |
| VALID_SEVERITY = {"low", "medium", "high"} | |
| VALID_CONFIDENCE = {"low", "medium", "high"} | |
| VALID_PRIORITY = {"low", "medium", "high"} | |
| WEATHER_VARIABLES = [ | |
| "temperature_2m_max", "temperature_2m_min", "rain_sum", "precipitation_sum", | |
| "wind_gusts_10m_max", "shortwave_radiation_sum", "temperature_2m_mean", | |
| "cloud_cover_mean", "et0_fao_evapotranspiration", | |
| "growing_degree_days_base_0_limit_50", "leaf_wetness_probability_mean", | |
| "vapour_pressure_deficit_max" | |
| ] | |
| # ------------------------------------------------------------------ | |
| # DATA CLASSES | |
| # ------------------------------------------------------------------ | |
| class AppState: | |
| state: str = STATE_READY | |
| is_analyzing: bool = False | |
| location: Optional[Dict] = None | |
| client_date: Optional[str] = None | |
| client_timezone: Optional[str] = None | |
| browser_date: Optional[str] = None | |
| browser_timezone: Optional[str] = None | |
| cache: Optional[Dict] = None # Now a SINGLE entry, not a dict of dates | |
| def to_dict(self): | |
| return { | |
| "state": self.state, | |
| "is_analyzing": self.is_analyzing, | |
| "location": self.location, | |
| "client_date": self.client_date, | |
| "client_timezone": self.client_timezone, | |
| "browser_date": self.browser_date, | |
| "browser_timezone": self.browser_timezone, | |
| "cache": self.cache, | |
| } | |
| def from_dict(cls, d): | |
| return cls( | |
| state=d.get("state", STATE_READY), | |
| is_analyzing=d.get("is_analyzing", False), | |
| location=d.get("location"), | |
| client_date=d.get("client_date"), | |
| client_timezone=d.get("client_timezone"), | |
| browser_date=d.get("browser_date"), | |
| browser_timezone=d.get("browser_timezone"), | |
| cache=d.get("cache"), | |
| ) | |
| # ------------------------------------------------------------------ | |
| # UTILITY FORMATTERS | |
| # ------------------------------------------------------------------ | |
| def validate_coordinates(lat: Any, lon: Any) -> Tuple[bool, str]: | |
| if lat is None or lon is None: | |
| return False, "Invalid location. Please enter a valid latitude and longitude." | |
| try: | |
| lat_f = float(lat) | |
| lon_f = float(lon) | |
| except (ValueError, TypeError): | |
| return False, "Invalid location. Please enter a valid latitude and longitude." | |
| if not (-90 <= lat_f <= 90): | |
| return False, "Invalid location. Latitude must be between -90 and 90." | |
| if not (-180 <= lon_f <= 180): | |
| return False, "Invalid location. Longitude must be between -180 and 180." | |
| return True, "" | |
| def validate_coordinates_ui(lat, lon, state_dict): | |
| state = AppState.from_dict(state_dict) | |
| if state.state == STATE_LOCKED_TODAY: | |
| return gr.update(interactive=False) | |
| if (lat is None or lat == 0) or (lon is None or lon == 0): | |
| return gr.update(interactive=False) | |
| return gr.update(interactive=True) | |
| def esc(value: Any) -> str: | |
| if value is None: | |
| return "" | |
| return html.escape(str(value)) | |
| def format_status(state: str, message: str = "") -> str: | |
| if state in PROGRESS_STEPS and state not in STATUS_VARIANTS: | |
| variant = "active" | |
| label = PROGRESS_STEPS[state][1] | |
| else: | |
| variant, label = STATUS_VARIANTS.get(state, ("neutral", state.replace("_", " ").title())) | |
| detail = f'<span class="status-detail">{esc(message)}</span>' if message else "" | |
| return ( | |
| f'<div class="status-pill status-pill--{variant}">' | |
| f'<span class="status-dot"></span>' | |
| f'<span class="status-label">{esc(label)}</span>' | |
| f'{detail}' | |
| f'</div>' | |
| ) | |
| def format_location_status(source: str, accuracy: Optional[float]) -> str: | |
| source_label = {"gps": "GPS", "ip": "IP geolocation"}.get(source, source or "Manual") | |
| accuracy_str = f" ±{accuracy:.0f}m" if accuracy else "" | |
| return ( | |
| f'<div class="status-pill status-pill--info">' | |
| f'<span class="status-dot"></span>' | |
| f'<span class="status-label">Location ready</span>' | |
| f'<span class="status-detail">{esc(source_label)}{accuracy_str}</span>' | |
| f'</div>' | |
| ) | |
| def render_step_tracker(state: str, error: bool = False) -> str: | |
| if state not in STEP_ORDER: | |
| return "" | |
| idx = STEP_ORDER.index(state) | |
| items = [] | |
| for i, label in enumerate(STEP_LABELS): | |
| is_done = i < idx or (i == idx and state == STATE_COMPLETED) | |
| if error and i == idx: | |
| cls, marker = "is-error", "×" | |
| elif is_done: | |
| cls, marker = "is-done", "✓" | |
| elif i == idx: | |
| cls, marker = "is-active", f"{i + 1:02d}" | |
| else: | |
| cls, marker = "is-pending", f"{i + 1:02d}" | |
| items.append( | |
| f'<div class="step {cls}">' | |
| f'<span class="step-marker">{marker}</span>' | |
| f'<span class="step-label">{esc(label)}</span>' | |
| f'</div>' | |
| ) | |
| if i < len(STEP_LABELS) - 1: | |
| filled = "filled" if is_done else "" | |
| items.append(f'<div class="step-connector {filled}"></div>') | |
| return f'<div class="step-tracker">{"".join(items)}</div>' | |
| def format_progress(state: str, error: bool = False) -> str: | |
| return render_step_tracker(state, error=error) | |
| def format_cache_info(cache_entry: Optional[Dict]) -> str: | |
| if not cache_entry: | |
| return "" | |
| # --- Tanggal analisis (sudah browser_date dari handle_weather_and_analyze) --- | |
| client_date = cache_entry.get("client_date", "Unknown") | |
| if client_date != "Unknown": | |
| try: | |
| client_date = datetime.strptime(client_date, "%Y-%m-%d").strftime("%d %b %Y") | |
| except Exception: | |
| pass | |
| # --- Waktu generated: konversi UTC → browser timezone --- | |
| created = cache_entry.get("created_at", "Unknown") | |
| browser_tz = cache_entry.get("browser_timezone") or cache_entry.get("client_timezone") or "UTC" | |
| if created != "Unknown": | |
| try: | |
| dt = datetime.fromisoformat(created) | |
| # Pastikan aware (UTC) | |
| if dt.tzinfo is None: | |
| dt = dt.replace(tzinfo=timezone.utc) | |
| if browser_tz and browser_tz != "UTC": | |
| try: | |
| from zoneinfo import ZoneInfo | |
| dt_local = dt.astimezone(ZoneInfo(browser_tz)) | |
| created = dt_local.strftime("%d %b %Y, %H:%M") + f" · {browser_tz}" | |
| except Exception: | |
| # Fallback kalau ZoneInfo tidak kenal timezone | |
| created = dt.strftime("%d %b %Y, %H:%M UTC") | |
| else: | |
| created = dt.strftime("%d %b %Y, %H:%M UTC") | |
| except Exception: | |
| pass | |
| location = cache_entry.get("location", {}) or {} | |
| source = location.get("source", "manual") | |
| source_label = {"gps": "GPS", "ip": "IP Geolocation", "manual": "Manual"}.get(source, source) | |
| return ( | |
| f'<div class="cache-card">' | |
| f'<div class="cache-card-icon">✓</div>' | |
| f'<div class="cache-card-body">' | |
| f'<div class="cache-card-title">Today’s analysis is ready</div>' | |
| f'<div class="cache-card-meta">' | |
| f'<span>{esc(client_date)}</span><span class="dot-sep">·</span><span>{esc(source_label)}</span>' | |
| f'</div>' | |
| f'<div class="cache-card-meta cache-card-meta--faint">Generated {esc(created)}</div>' | |
| f'</div>' | |
| f'</div>' | |
| ) | |
| def render_placeholder(title: str, message: str, variant: str = "neutral") -> str: | |
| return ( | |
| f'<div class="placeholder-card placeholder-card--{variant}">' | |
| f'<div class="placeholder-mark"></div>' | |
| f'<div class="placeholder-title">{esc(title)}</div>' | |
| f'<div class="placeholder-message">{esc(message)}</div>' | |
| f'</div>' | |
| ) | |
| def render_sponsors_html(sponsors: List[Dict]) -> str: | |
| if not sponsors: | |
| return '<div class="sponsor-empty">No sponsors listed.</div>' | |
| items = [] | |
| for s in sponsors: | |
| items.append( | |
| f'<a class="sponsor-item" href="{esc(s.get("link", "#"))}" ' | |
| f'target="_blank" rel="noopener noreferrer" title="{esc(s.get("name", ""))}">' | |
| f'<img class="sponsor-img" src="{esc(s.get("image", ""))}" ' | |
| f'alt="{esc(s.get("name", ""))}" loading="lazy">' | |
| f'</a>' | |
| ) | |
| return f'<div class="sponsor-grid">{"".join(items)}</div>' | |
| def severity_badge(sev: Optional[str]) -> str: | |
| sev_l = (sev or "unknown").lower() | |
| cls = {"low": "badge--good", "medium": "badge--warn", "high": "badge--bad"}.get(sev_l, "badge--neutral") | |
| return f'<span class="badge {cls}">{esc(sev_l.upper())}</span>' | |
| def priority_badge(pri: Optional[str]) -> str: | |
| pri_l = (pri or "unknown").lower() | |
| cls = {"low": "badge--good", "medium": "badge--warn", "high": "badge--bad"}.get(pri_l, "badge--neutral") | |
| return f'<span class="badge {cls}">{esc(pri_l.upper())} PRI.</span>' | |
| def confidence_badge(conf: Optional[str]) -> str: | |
| conf_l = (conf or "unknown").lower() | |
| cls = {"high": "badge--good", "medium": "badge--warn", "low": "badge--neutral"}.get(conf_l, "badge--neutral") | |
| return f'<span class="badge {cls} badge--outline">{esc(conf_l.upper())} CONF.</span>' | |
| def render_summary_tab(analysis: Dict) -> str: | |
| if not analysis: | |
| return render_placeholder("No analysis yet", "Enter coordinates and select Analyze to generate today's insight.") | |
| parts = ['<div class="insight-card">'] | |
| parts.append(f'<p class="insight-summary">{esc(analysis.get("summary", "No summary available."))}</p>') | |
| overall = analysis.get("overall_confidence", "") | |
| if overall: | |
| parts.append(f'<div class="insight-footer">Overall confidence {confidence_badge(overall)}</div>') | |
| parts.append('</div>') | |
| return "".join(parts) | |
| def render_historical_tab(analysis: Dict) -> str: | |
| if not analysis: | |
| return render_placeholder("No data yet", "Run analysis to view historical records.") | |
| hist = analysis.get("historical", {}) or {} | |
| parts = ['<div class="insight-card">'] | |
| parts.append('<div class="insight-section">') | |
| parts.append('<div class="section-eyebrow">Historical · Last 7 Days</div>') | |
| # New format: condition + impact | |
| if hist.get("condition"): | |
| parts.append(f'<p class="section-text"><strong>Kondisi:</strong> {esc(hist["condition"])}</p>') | |
| if hist.get("impact"): | |
| parts.append(f'<p class="section-text"><strong>Dampak:</strong> {esc(hist["impact"])}</p>') | |
| # Old format: summary + key_conditions (backward-compatible) | |
| if hist.get("summary"): | |
| parts.append(f'<p class="section-text">{esc(hist["summary"])}</p>') | |
| conds = hist.get("key_conditions") or [] | |
| if conds: | |
| parts.append('<ul class="condition-list">' + "".join(f'<li>{esc(c)}</li>' for c in conds) + '</ul>') | |
| # Fallback if nothing found | |
| if not hist.get("condition") and not hist.get("impact") and not hist.get("summary") and not conds: | |
| parts.append('<p class="section-text">No historical data recorded.</p>') | |
| parts.append('</div></div>') | |
| return "".join(parts) | |
| def render_forecast_tab(analysis: Dict) -> str: | |
| if not analysis: | |
| return render_placeholder("No data yet", "Run analysis to view forecast records.") | |
| fcst = analysis.get("forecast", {}) or {} | |
| parts = ['<div class="insight-card">'] | |
| parts.append('<div class="insight-section">') | |
| parts.append('<div class="section-eyebrow">Forecast · Next 7 Days</div>') | |
| # New format: condition + impact | |
| if fcst.get("condition"): | |
| parts.append(f'<p class="section-text"><strong>Kondisi:</strong> {esc(fcst["condition"])}</p>') | |
| if fcst.get("impact"): | |
| parts.append(f'<p class="section-text"><strong>Dampak:</strong> {esc(fcst["impact"])}</p>') | |
| # Old format: summary + key_conditions (backward-compatible) | |
| if fcst.get("summary"): | |
| parts.append(f'<p class="section-text">{esc(fcst["summary"])}</p>') | |
| conds = fcst.get("key_conditions") or [] | |
| if conds: | |
| parts.append('<ul class="condition-list">' + "".join(f'<li>{esc(c)}</li>' for c in conds) + '</ul>') | |
| # Fallback if nothing found | |
| if not fcst.get("condition") and not fcst.get("impact") and not fcst.get("summary") and not conds: | |
| parts.append('<p class="section-text">No forecast data recorded.</p>') | |
| parts.append('</div></div>') | |
| return "".join(parts) | |
| def render_risks_tab(analysis: Dict) -> str: | |
| if not analysis: | |
| return render_placeholder("No data yet", "Run analysis to view risk signals.") | |
| risks = analysis.get("risks") or [] | |
| parts = ['<div class="insight-card">'] | |
| parts.append('<div class="insight-section">') | |
| parts.append('<div class="section-eyebrow">Risk Signals</div>') | |
| if risks: | |
| parts.append('<div class="risk-list">') | |
| for risk in risks: | |
| # Backward-compatible: old data uses "type", new data uses "title" | |
| title = str(risk.get("title") or risk.get("type", "Unknown Risk")) | |
| # Old data uses "confidence", new data doesn't have it — just show severity | |
| badges = severity_badge(risk.get("severity")) | |
| parts.append('<div class="risk-item">') | |
| parts.append( | |
| f'<div class="risk-item-head">' | |
| f'<span class="risk-item-title">{esc(title)}</span>' | |
| f'<span class="risk-item-badges">{badges}</span>' | |
| f'</div>' | |
| ) | |
| # New format has "description", old format doesn't | |
| if risk.get("description"): | |
| parts.append(f'<p class="section-text" style="margin:6px 0 4px;font-size:13px;">{esc(risk["description"])}</p>') | |
| if risk.get("evidence"): | |
| parts.append(f'<p class="risk-item-evidence">{esc(risk["evidence"])}</p>') | |
| # New format has "period", old format doesn't | |
| if risk.get("period"): | |
| parts.append(f'<p class="risk-item-evidence" style="color:var(--info);margin-top:4px;">📅 {esc(risk["period"])}</p>') | |
| parts.append('</div>') | |
| parts.append('</div>') | |
| else: | |
| parts.append('<p class="section-text">No significant weather risks detected.</p>') | |
| parts.append('</div></div>') | |
| return "".join(parts) | |
| def render_recommendations_tab(analysis: Dict) -> str: | |
| if not analysis: | |
| return render_placeholder("No data yet", "Run analysis to view recommendations.") | |
| parts = ['<div class="insight-card">'] | |
| parts.append('<div class="insight-section insight-section--recommendation" style="margin-bottom:0;">') | |
| parts.append('<div class="section-eyebrow">Recommendation</div>') | |
| recs = analysis.get("recommendations") or [] | |
| if recs: | |
| parts.append('<div class="risk-list">') | |
| for rec in recs: | |
| # Backward-compatible: handle old string format AND new object format | |
| if isinstance(rec, str): | |
| # Old format: just a string | |
| action = rec | |
| reason = "" | |
| priority = "" | |
| else: | |
| # New format: object with action, reason, priority | |
| action = str(rec.get("action", "")) | |
| reason = str(rec.get("reason", "")) | |
| priority = rec.get("priority", "") | |
| parts.append('<div class="risk-item" style="border-color:rgba(232,163,61,0.25);">') | |
| parts.append( | |
| f'<div class="risk-item-head">' | |
| f'<span class="risk-item-title">{esc(action)}</span>' | |
| f'<span class="risk-item-badges">{priority_badge(priority)}</span>' | |
| f'</div>' | |
| ) | |
| if reason: | |
| parts.append(f'<p class="risk-item-evidence">{esc(reason)}</p>') | |
| parts.append('</div>') | |
| parts.append('</div>') | |
| else: | |
| parts.append('<p class="section-text">No immediate action indicated.</p>') | |
| parts.append('</div></div>') | |
| return "".join(parts) | |
| # ------------------------------------------------------------------ | |
| # 3. WEATHER SERVICE | |
| # ------------------------------------------------------------------ | |
| def fetch_weather_data(lat: float, lon: float) -> Tuple[Optional[Dict], str]: | |
| variables_str = ",".join(WEATHER_VARIABLES) | |
| url = ( | |
| f"{OPEN_METEO_URL}?latitude={lat}&longitude={lon}" | |
| f"&daily={variables_str}" | |
| f"&timezone=auto&past_days=7&forecast_days=7" | |
| ) | |
| try: | |
| resp = requests.get(url, timeout=30) | |
| if resp.status_code != 200: | |
| return None, f"Weather data unavailable. Please try again later. (HTTP {resp.status_code})" | |
| data = resp.json() | |
| if not isinstance(data, dict): | |
| return None, "Weather data unavailable. Please try again later. (Invalid JSON)" | |
| if "daily" not in data: | |
| return None, "Weather data unavailable. Please try again later. (Missing daily data)" | |
| daily = data["daily"] | |
| required_vars = ["time"] + WEATHER_VARIABLES | |
| for var in required_vars: | |
| if var not in daily: | |
| return None, f"Weather data unavailable. Please try again later. (Missing variable: {var})" | |
| dates = daily["time"] | |
| if not isinstance(dates, list) or len(dates) == 0: | |
| return None, "Weather data unavailable. Please try again later. (Invalid date array)" | |
| if len(dates) < 8: | |
| return None, f"Weather data unavailable. Please try again later. (Insufficient data: {len(dates)} days)" | |
| return data, "" | |
| except requests.Timeout: | |
| return None, "Weather data unavailable. Request timed out. Please try again later." | |
| except Exception as e: | |
| logger.error(f"Weather fetch error: {e}") | |
| return None, "Weather data unavailable. Please try again later." | |
| 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): | |
| state = AppState.from_dict(state_dict) | |
| cache_entry = state.cache | |
| if not weather_json_str: | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_READY), | |
| format_progress(STATE_READY), | |
| format_cache_info(cache_entry), | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_summary_tab({}), | |
| render_historical_tab({}), | |
| render_forecast_tab({}), | |
| render_risks_tab({}), | |
| render_recommendations_tab({}), | |
| json.dumps(cache_entry) if cache_entry else "" | |
| ) | |
| return | |
| try: | |
| raw_weather = json.loads(weather_json_str) | |
| except Exception as exc: | |
| err_msg = f"Failed to parse weather data: {exc}" | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, err_msg), | |
| format_progress(STATE_FETCHING_WEATHER, error=True), "", | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_placeholder("Analysis failed", err_msg, "error"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| gr.update(value="") | |
| ) | |
| return | |
| if "error" in raw_weather: | |
| error_msg = raw_weather["error"] | |
| yield ( | |
| state.to_dict(), gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, error_msg), | |
| format_progress(STATE_FETCHING_WEATHER, error=True), "", | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_placeholder("Analysis failed", error_msg, "error"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| gr.update(value="") | |
| ) | |
| return | |
| # <-- UBAH: gunakan tanggal browser, fallback ke server | |
| client_date = state.browser_date or datetime.now().strftime("%Y-%m-%d") | |
| state.client_date = client_date | |
| if "error" in raw_weather: | |
| error_msg = raw_weather["error"] | |
| yield ( | |
| state.to_dict(), gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, error_msg), | |
| format_progress(STATE_FETCHING_WEATHER, error=True), "", | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_placeholder("Analysis failed", error_msg, "error"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| gr.update(value="") | |
| ) | |
| return | |
| now = datetime.now() | |
| state.state = STATE_PROCESSING_DATA | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_PROCESSING_DATA), | |
| format_progress(STATE_PROCESSING_DATA), | |
| "", | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_placeholder("Processing weather data", "Splitting historical and forecast windows and computing derived indices.", "active"), | |
| render_placeholder("No data yet", "Waiting for processing...", "neutral"), | |
| render_placeholder("No data yet", "Waiting for processing...", "neutral"), | |
| render_placeholder("No data yet", "Waiting for processing...", "neutral"), | |
| render_placeholder("No data yet", "Waiting for processing...", "neutral"), | |
| gr.update(value="") | |
| ) | |
| normalized, norm_error = normalize_weather_data(raw_weather, lat, lon, client_date) | |
| if norm_error: | |
| failed_step = state.state | |
| state.is_analyzing = False | |
| state.state = STATE_ERROR | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, norm_error), | |
| format_progress(failed_step, error=True), "", | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_placeholder("Analysis failed", norm_error, "error"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| gr.update(value="") | |
| ) | |
| return | |
| state.state = STATE_GENERATING_INSIGHT | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_GENERATING_INSIGHT), | |
| format_progress(STATE_GENERATING_INSIGHT), | |
| "", | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_placeholder("Generating weather insight", "Gemini is interpreting the data for risks and recommendations.", "active"), | |
| render_placeholder("No data yet", "Waiting for insight...", "neutral"), | |
| render_placeholder("No data yet", "Waiting for insight...", "neutral"), | |
| render_placeholder("No data yet", "Waiting for insight...", "neutral"), | |
| render_placeholder("No data yet", "Waiting for insight...", "neutral"), | |
| gr.update(value="") | |
| ) | |
| payload = build_llm_payload(normalized, lat, lon, client_date, crop, phenology, notes, current_concern) | |
| llm_output, gemini_error = call_gemini(payload) | |
| if gemini_error: | |
| failed_step = state.state | |
| state.is_analyzing = False | |
| state.state = STATE_ERROR | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, gemini_error), | |
| format_progress(failed_step, error=True), "", | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_placeholder("Analysis failed", gemini_error, "error"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| gr.update(value="") | |
| ) | |
| return | |
| state.state = STATE_VALIDATING_OUTPUT | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_VALIDATING_OUTPUT), | |
| format_progress(STATE_VALIDATING_OUTPUT), | |
| "", | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_placeholder("Validating analysis", "Checking the response against the expected schema.", "active"), | |
| render_placeholder("No data yet", "Waiting for validation...", "neutral"), | |
| render_placeholder("No data yet", "Waiting for validation...", "neutral"), | |
| render_placeholder("No data yet", "Waiting for validation...", "neutral"), | |
| render_placeholder("No data yet", "Waiting for validation...", "neutral"), | |
| gr.update(value="") | |
| ) | |
| valid, val_error = validate_llm_output(llm_output) | |
| if not valid: | |
| failed_step = state.state | |
| state.is_analyzing = False | |
| state.state = STATE_ERROR | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, val_error), | |
| format_progress(failed_step, error=True), "", | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_placeholder("Analysis failed", val_error, "error"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| render_placeholder("No data yet", "Analysis interrupted.", "neutral"), | |
| gr.update(value="") | |
| ) | |
| return | |
| state.state = STATE_COMPLETED | |
| timezone_str = raw_weather.get("timezone", "UTC") | |
| location_date = normalized.get("meta", {}).get("location_date", client_date) | |
| cache_entry = { | |
| "location": state.location, | |
| "client_date": client_date, | |
| "location_date": location_date, | |
| "location_timezone": timezone_str, | |
| "client_timezone": state.client_timezone or "UTC", | |
| "field_context": { | |
| "crop_type": crop, | |
| "phenology_phase": phenology, | |
| "field_notes": notes, | |
| "current_concern": current_concern, | |
| }, | |
| "raw_weather": raw_weather, | |
| "analysis": llm_output, | |
| "created_at": datetime.now(timezone.utc).isoformat() | |
| } | |
| state.cache = cache_entry | |
| state.is_analyzing = False | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_COMPLETED), | |
| format_progress(STATE_COMPLETED), | |
| format_cache_info(cache_entry), | |
| gr.update(visible=False), # initial_placeholder hidden | |
| gr.update(visible=True), # view_insight_btn visible | |
| gr.update(visible=False), # tabs_container hidden until clicked | |
| render_summary_tab(llm_output), | |
| render_historical_tab(llm_output), | |
| render_forecast_tab(llm_output), | |
| render_risks_tab(llm_output), | |
| render_recommendations_tab(llm_output), | |
| json.dumps(cache_entry) | |
| ) | |
| state.state = STATE_LOCKED_TODAY | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_LOCKED_TODAY, "Today's analysis complete. Return tomorrow for a new analysis."), | |
| "", | |
| format_cache_info(cache_entry), | |
| gr.update(visible=False), # initial_placeholder hidden | |
| gr.update(visible=True), # view_insight_btn visible | |
| gr.update(visible=False), # tabs_container hidden until clicked | |
| render_summary_tab(llm_output), | |
| render_historical_tab(llm_output), | |
| render_forecast_tab(llm_output), | |
| render_risks_tab(llm_output), | |
| render_recommendations_tab(llm_output), | |
| json.dumps(cache_entry) | |
| ) | |
| # ------------------------------------------------------------------ | |
| # 4. ANALYTICS ENGINE | |
| # ------------------------------------------------------------------ | |
| def normalize_weather_data(raw_data: Dict, lat: float, lon: float, client_date: str) -> Tuple[Optional[Dict], str]: | |
| try: | |
| timezone_str = raw_data.get("timezone", "UTC") | |
| dates = raw_data["daily"]["time"] | |
| location_date = dates[7] if len(dates) > 7 else client_date | |
| daily = raw_data["daily"] | |
| historical_dates = dates[:7] | |
| today_date = [dates[7]] if len(dates) > 7 else [] | |
| forecast_dates = dates[8:] if len(dates) > 8 else [] | |
| def extract_block(date_list): | |
| if not date_list: | |
| return {} | |
| result = {"time": date_list} | |
| for var in WEATHER_VARIABLES: | |
| values = daily.get(var, []) | |
| idx_start = dates.index(date_list[0]) | |
| idx_end = idx_start + len(date_list) | |
| result[var] = values[idx_start:idx_end] | |
| return result | |
| historical = extract_block(historical_dates) | |
| today = extract_block(today_date) | |
| forecast = extract_block(forecast_dates) | |
| units = raw_data.get("daily_units", {}) | |
| units_filtered = {k: v for k, v in units.items() if k != "time"} | |
| normalized = { | |
| "meta": { | |
| "location": { | |
| "latitude": raw_data.get("latitude", lat), | |
| "longitude": raw_data.get("longitude", lon), | |
| "timezone": timezone_str, | |
| "elevation_m": raw_data.get("elevation") | |
| }, | |
| "client_date": client_date, | |
| "location_date": location_date, | |
| "analysis_period": { | |
| "historical": f"{len(historical_dates)} days", | |
| "forecast": f"{len(forecast_dates)} days", | |
| "anchor_date": client_date | |
| } | |
| }, | |
| "units": units_filtered, | |
| "historical": historical, | |
| "today": today, | |
| "forecast": forecast | |
| } | |
| return normalized, "" | |
| except Exception as e: | |
| logger.error(f"Normalization error: {e}") | |
| return None, "Failed to process weather data. Please try again later." | |
| # ------------------------------------------------------------------ | |
| # 5. LLM ENGINE | |
| # ------------------------------------------------------------------ | |
| def build_llm_payload(normalized_data: Dict, lat: float, lon: float, client_date: str, crop: str = "", phenology: str = "", notes: str = "", current_concern: str = "") -> Dict: | |
| meta = normalized_data.get("meta", {}) | |
| location = meta.get("location", {}) | |
| return { | |
| "location": { | |
| "latitude": lat, | |
| "longitude": lon, | |
| "timezone": location.get("timezone", "UTC") | |
| }, | |
| "field_context": { | |
| "crop_type": crop or "Tidak ditentukan", | |
| "phenology_phase": phenology or "Tidak ditentukan", | |
| "field_notes": notes or "Tidak ada catatan khusus", | |
| "current_concern": current_concern or "Tidak ada concern khusus" | |
| }, | |
| "analysis_period": { | |
| "historical": meta.get("analysis_period", {}).get("historical", "7 days"), | |
| "forecast": meta.get("analysis_period", {}).get("forecast", "7 days"), | |
| "anchor_date": client_date, | |
| "anchor_type": "client_time", | |
| "location_date": meta.get("location_date", client_date), | |
| "location_timezone": location.get("timezone", "UTC") | |
| }, | |
| "units": normalized_data.get("units", {}), | |
| "historical": {"daily": normalized_data.get("historical", {})}, | |
| "forecast": {"daily": normalized_data.get("forecast", {})} | |
| } | |
| def call_gemini(payload: Dict) -> Tuple[Optional[Dict], str]: | |
| system_prompt = load_text("prompts/system_prompt.txt") | |
| if not system_prompt: | |
| return None, "System prompt file is missing or empty. Please check prompts/system_prompt.txt" | |
| user_prompt = f"Weather data for analysis:\n\n{json.dumps(payload, indent=2)}" | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| candidate_keys = key_manager.get_rotation_order() | |
| last_error_msg = "Unknown error" | |
| for key in candidate_keys: | |
| if not key_manager._is_available(key): | |
| continue | |
| try: | |
| response = completion( | |
| model="gemini/gemini-3.5-flash", | |
| messages=messages, | |
| api_key=key, | |
| temperature=1.0, | |
| max_tokens=4000, | |
| timeout=30, | |
| ) | |
| content = response.choices[0].message.content | |
| json_match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content) | |
| if json_match: | |
| content = json_match.group(1) | |
| content = content.strip() | |
| start_idx = content.find("{") | |
| end_idx = content.rfind("}") | |
| if start_idx != -1 and end_idx != -1: | |
| content = content[start_idx:end_idx + 1] | |
| result = json.loads(content) | |
| return result, "" | |
| except json.JSONDecodeError as e: | |
| logger.error(f"JSON decode error: {e}") | |
| return None, "Analysis could not be validated. Please try again. (Invalid JSON)" | |
| except Exception as e: | |
| kind = classify_gemini_error(e) | |
| logger.warning(f"Key ...{key[-6:]} gagal ({kind}): {e}") | |
| if kind == "rpd": | |
| key_manager.mark_rpd_exhausted(key) | |
| last_error_msg = "Satu atau lebih API key mencapai limit harian." | |
| continue | |
| elif kind == "rpm": | |
| last_error_msg = "Key sedang sibuk (limit per menit), mencoba key lain." | |
| continue | |
| else: | |
| last_error_msg = str(e) | |
| continue | |
| return None, ( | |
| "Weather data was retrieved, but the analysis could not be generated. " | |
| f"Semua API key gagal atau kena limit. ({last_error_msg})" | |
| ) | |
| # ------------------------------------------------------------------ | |
| # 6. OUTPUT + CACHE | |
| # ------------------------------------------------------------------ | |
| def build_export_file(cache_entry: Optional[Dict]) -> Optional[str]: | |
| """ | |
| Rangkai satu file TXT (plain text, mudah dibaca manusia maupun LLM) | |
| berisi: input pengguna (lokasi & konteks lahan), data cuaca mentah | |
| (historical + forecast dari Open-Meteo), dan hasil analisis Gemini. | |
| File ini dimaksudkan agar pengguna bisa memakainya sebagai context | |
| input di LLM lain untuk analisis lanjutan. | |
| """ | |
| if not cache_entry: | |
| return None | |
| location = cache_entry.get("location", {}) or {} | |
| field_context = cache_entry.get("field_context", {}) or {} | |
| analysis = cache_entry.get("analysis", {}) or {} | |
| lines: List[str] = [] | |
| lines.append("=== EXPORT DATA ANALISIS CUACA ===") | |
| lines.append(f"Dibuat pada : {datetime.now(timezone.utc).isoformat()}") | |
| lines.append("Tujuan : Context data untuk analisis lanjutan") | |
| lines.append("Sumber App : DigiTanist/tanam") | |
| lines.append("") | |
| lines.append("--- INPUT PENGGUNA ---") | |
| lines.append(f"Latitude : {location.get('latitude')}") | |
| lines.append(f"Longitude : {location.get('longitude')}") | |
| lines.append(f"Sumber lokasi : {location.get('source') or '-'}") | |
| if location.get("accuracy_m"): | |
| lines.append(f"Akurasi GPS : {location.get('accuracy_m')} m") | |
| lines.append(f"Tanggal analisis : {cache_entry.get('client_date')}") | |
| lines.append(f"Timezone lokasi : {cache_entry.get('location_timezone')}") | |
| lines.append("") | |
| lines.append("Konteks Lahan:") | |
| lines.append(f"- Jenis Tanaman : {field_context.get('crop_type') or '-'}") | |
| lines.append(f"- Fase Fenologi : {field_context.get('phenology_phase') or '-'}") | |
| lines.append(f"- Catatan Lapangan : {field_context.get('field_notes') or '-'}") | |
| lines.append(f"- Current Concern : {field_context.get('current_concern') or '-'}") | |
| lines.append("") | |
| lines.append("--- DATA CUACA MENTAH (Ecwf, historical 7 hari + forecast 7 hari) ---") | |
| lines.append(json.dumps(cache_entry.get("raw_weather", {}), ensure_ascii=False, indent=2)) | |
| lines.append("") | |
| lines.append("--- HASIL ANALISIS GEMINI AI ---") | |
| lines.append(f"Ringkasan: {analysis.get('summary', '-')}") | |
| lines.append("") | |
| hist = analysis.get("historical", {}) or {} | |
| lines.append("Historical (7 hari terakhir):") | |
| lines.append(f" Kondisi : {hist.get('condition', '-')}") | |
| lines.append(f" Dampak : {hist.get('impact', '-')}") | |
| lines.append("") | |
| fcst = analysis.get("forecast", {}) or {} | |
| lines.append("Forecast (7 hari ke depan):") | |
| lines.append(f" Kondisi : {fcst.get('condition', '-')}") | |
| lines.append(f" Dampak : {fcst.get('impact', '-')}") | |
| lines.append("") | |
| risks = analysis.get("risks", []) or [] | |
| lines.append(f"Risiko ({len(risks)}):") | |
| if risks: | |
| for i, r in enumerate(risks, 1): | |
| lines.append(f" {i}. {r.get('title', '-')} [severity: {r.get('severity', '-')}]") | |
| lines.append(f" Deskripsi : {r.get('description', '-')}") | |
| lines.append(f" Bukti : {r.get('evidence', '-')}") | |
| lines.append(f" Periode : {r.get('period', '-')}") | |
| else: | |
| lines.append(" Tidak ada risiko signifikan.") | |
| lines.append("") | |
| recs = analysis.get("recommendations", []) or [] | |
| lines.append(f"Rekomendasi ({len(recs)}):") | |
| if recs: | |
| for i, r in enumerate(recs, 1): | |
| lines.append(f" {i}. {r.get('action', '-')} [priority: {r.get('priority', '-')}]") | |
| lines.append(f" Alasan : {r.get('reason', '-')}") | |
| else: | |
| lines.append(" Tidak ada tindakan khusus diperlukan.") | |
| lines.append("") | |
| lines.append(f"Overall Confidence: {analysis.get('overall_confidence', '-')}") | |
| content = "\n".join(lines) | |
| try: | |
| os.makedirs(EXPORT_DIR, exist_ok=True) | |
| date_tag = (cache_entry.get("client_date") or "unknown").replace("-", "") | |
| unique_id = uuid.uuid4().hex[:8] # cegah tabrakan nama file antar-user/klik | |
| path = os.path.join(EXPORT_DIR, f"weather_analysis_{date_tag}_{unique_id}.txt") | |
| with open(path, "w", encoding="utf-8") as f: | |
| f.write(content) | |
| return path | |
| except Exception as e: | |
| logger.error(f"Failed to build export file: {e}") | |
| return None | |
| def validate_llm_output(output: Dict) -> Tuple[bool, str]: | |
| required_fields = ["summary", "historical", "forecast", "risks", "recommendations", "overall_confidence"] | |
| for field in required_fields: | |
| if field not in output: | |
| return False, f"Analysis could not be validated. Missing field: {field}" | |
| # Validate historical structure | |
| hist = output.get("historical", {}) | |
| if not isinstance(hist, dict): | |
| return False, "Analysis could not be validated. Invalid historical format." | |
| if "condition" not in hist or "impact" not in hist: | |
| return False, "Analysis could not be validated. Historical missing condition or impact." | |
| # Validate forecast structure | |
| fcst = output.get("forecast", {}) | |
| if not isinstance(fcst, dict): | |
| return False, "Analysis could not be validated. Invalid forecast format." | |
| if "condition" not in fcst or "impact" not in fcst: | |
| return False, "Analysis could not be validated. Forecast missing condition or impact." | |
| # Validate risks | |
| if not isinstance(output["risks"], list): | |
| return False, "Analysis could not be validated. Invalid risks format." | |
| for i, risk in enumerate(output["risks"]): | |
| if not isinstance(risk, dict): | |
| return False, f"Analysis could not be validated. Invalid risk at index {i}." | |
| risk_required = ["title", "description", "evidence", "period", "severity"] | |
| for rf in risk_required: | |
| if rf not in risk: | |
| return False, f"Analysis could not be validated. Risk {i} missing field: {rf}" | |
| if risk.get("severity") not in VALID_SEVERITY: | |
| return False, f"Analysis could not be validated. Invalid severity: {risk.get('severity')}" | |
| # Validate recommendations | |
| if not isinstance(output["recommendations"], list): | |
| return False, "Analysis could not be validated. Invalid recommendations format." | |
| for i, rec in enumerate(output["recommendations"]): | |
| if not isinstance(rec, dict): | |
| return False, f"Analysis could not be validated. Invalid recommendation at index {i}." | |
| rec_required = ["action", "reason", "priority"] | |
| for rf in rec_required: | |
| if rf not in rec: | |
| return False, f"Analysis could not be validated. Recommendation {i} missing field: {rf}" | |
| if rec.get("priority") not in VALID_PRIORITY: | |
| return False, f"Analysis could not be validated. Invalid priority: {rec.get('priority')}" | |
| # Validate overall_confidence | |
| if output.get("overall_confidence") not in VALID_CONFIDENCE: | |
| return False, f"Analysis could not be validated. Invalid overall_confidence." | |
| return True, "" | |
| # ------------------------------------------------------------------ | |
| # MAIN HANDLERS | |
| # ------------------------------------------------------------------ | |
| def init_app(composite_json: str): | |
| cache_entry = None | |
| browser_date = None | |
| browser_timezone = "UTC" | |
| if composite_json and composite_json.strip() and composite_json != '{}': | |
| try: | |
| parsed = json.loads(composite_json) | |
| if isinstance(parsed, dict): | |
| # Format BARU: object composite dari JS | |
| if "browser_date" in parsed: | |
| cache_raw = parsed.get("cache", "{}") | |
| browser_date = parsed.get("browser_date") | |
| browser_timezone = parsed.get("browser_timezone", "UTC") | |
| if isinstance(cache_raw, str) and cache_raw.strip() and cache_raw != '{}': | |
| cache_parsed = json.loads(cache_raw) | |
| if isinstance(cache_parsed, dict) and "analysis" in cache_parsed: | |
| cache_entry = cache_parsed | |
| # Format LAMA: langsung cache entry (backward-compatible) | |
| elif "analysis" in parsed: | |
| cache_entry = parsed | |
| except Exception: | |
| cache_entry = None | |
| # Fallback ke server time jika browser tidak mengirimkan waktu | |
| if not browser_date: | |
| now = datetime.now() | |
| browser_date = now.strftime("%Y-%m-%d") | |
| browser_timezone = "UTC" | |
| # <-- KUNCI: bandingkan cache dengan TANGGAL BROWSER, bukan tanggal server | |
| today_cache = cache_entry if ( | |
| cache_entry and cache_entry.get("client_date") == browser_date | |
| ) else None | |
| state = AppState( | |
| state=STATE_READY, | |
| is_analyzing=False, | |
| client_date=browser_date, | |
| client_timezone=browser_timezone, | |
| browser_date=browser_date, # <-- BARU | |
| browser_timezone=browser_timezone, # <-- BARU | |
| cache=today_cache | |
| ) | |
| if today_cache: | |
| state.state = STATE_LOCKED_TODAY | |
| location = today_cache.get("location", {}) | |
| lat = location.get("latitude", 0) | |
| lon = location.get("longitude", 0) | |
| analysis = today_cache.get("analysis", {}) | |
| return ( | |
| state.to_dict(), lat, lon, | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_LOCKED_TODAY, "Today's analysis already exists."), | |
| "", | |
| format_cache_info(today_cache), | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| render_summary_tab(analysis), | |
| render_historical_tab(analysis), | |
| render_forecast_tab(analysis), | |
| render_risks_tab(analysis), | |
| render_recommendations_tab(analysis), | |
| gr.update(value="") | |
| ) | |
| else: | |
| return ( | |
| state.to_dict(), 0, 0, | |
| gr.update(interactive=True), | |
| gr.update(interactive=False), | |
| format_status(STATE_READY), | |
| "", | |
| "", | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| render_summary_tab({}), | |
| render_historical_tab({}), | |
| render_forecast_tab({}), | |
| render_risks_tab({}), | |
| render_recommendations_tab({}), | |
| gr.update(value="") | |
| ) | |
| def handle_gps_result(gps_json: str, state_dict: Dict): | |
| state = AppState.from_dict(state_dict) | |
| if not gps_json: | |
| return state.to_dict(), 0, 0, format_status(STATE_ERROR, "No location data received.") | |
| try: | |
| data = json.loads(gps_json) | |
| except Exception: | |
| return state.to_dict(), 0, 0, format_status(STATE_ERROR, "Invalid location data.") | |
| if "error" in data: | |
| return state.to_dict(), 0, 0, format_status(STATE_ERROR, data["error"]) | |
| lat = data.get("latitude") | |
| lon = data.get("longitude") | |
| source = data.get("source", "unknown") | |
| accuracy = data.get("accuracy_m") | |
| valid, msg = validate_coordinates(lat, lon) | |
| if not valid: | |
| return state.to_dict(), 0, 0, format_status(STATE_ERROR, msg) | |
| state.location = { | |
| "latitude": lat, | |
| "longitude": lon, | |
| "source": source, | |
| "accuracy_m": accuracy | |
| } | |
| state.state = STATE_LOCATION_READY | |
| status = format_location_status(source, accuracy) | |
| return state.to_dict(), lat, lon, status | |
| def handle_analyze(lat: float, lon: float, state_dict: Dict): | |
| state = AppState.from_dict(state_dict) | |
| cache_entry = state.cache # Single entry, not a dict | |
| if state.is_analyzing: | |
| yield ( | |
| state.to_dict(), | |
| gr.update(), gr.update(), | |
| format_status(STATE_ANALYZING, "Analysis already in progress."), | |
| "", "", | |
| render_placeholder("Analysis in progress", "Please wait for the current analysis to finish.", "active"), | |
| gr.update(value="") | |
| ) | |
| return | |
| valid, msg = validate_coordinates(lat, lon) | |
| if not valid: | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, msg), | |
| "", "", | |
| render_placeholder("Invalid location", msg, "error"), | |
| gr.update(value="") | |
| ) | |
| return | |
| prev_source = (state.location or {}).get("source", "manual") | |
| prev_accuracy = (state.location or {}).get("accuracy_m") | |
| state.location = { | |
| "latitude": lat, | |
| "longitude": lon, | |
| "source": prev_source, | |
| "accuracy_m": prev_accuracy | |
| } | |
| client_date = state.browser_date or datetime.now().strftime("%Y-%m-%d") | |
| state.client_date = client_date | |
| # Check if today's analysis already exists in the single cache entry | |
| if cache_entry and cache_entry.get("client_date") == client_date: | |
| state.state = STATE_LOCKED_TODAY | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_LOCKED_TODAY, "Today's analysis already exists."), | |
| "", | |
| format_cache_info(cache_entry), | |
| render_analysis(cache_entry.get("analysis", {})), | |
| gr.update(value="") | |
| ) | |
| return | |
| state.is_analyzing = True | |
| state.state = STATE_ANALYZING | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_ANALYZING), | |
| format_progress(STATE_ANALYZING), | |
| "", | |
| render_placeholder("Starting analysis", "Preparing to fetch weather data...", "active"), | |
| gr.update(value="") | |
| ) | |
| state.state = STATE_FETCHING_WEATHER | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_FETCHING_WEATHER), | |
| format_progress(STATE_FETCHING_WEATHER), | |
| "", | |
| render_placeholder("Fetching weather data", "Retrieving historical and forecast records from Open-Meteo.", "active"), | |
| gr.update(value="") | |
| ) | |
| raw_weather, error = fetch_weather_data(lat, lon) | |
| if error: | |
| failed_step = state.state | |
| state.is_analyzing = False | |
| state.state = STATE_ERROR | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, error), | |
| format_progress(failed_step, error=True), "", | |
| render_placeholder("Analysis failed", error, "error"), | |
| gr.update(value="") | |
| ) | |
| return | |
| state.state = STATE_PROCESSING_DATA | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_PROCESSING_DATA), | |
| format_progress(STATE_PROCESSING_DATA), | |
| "", | |
| render_placeholder("Processing weather data", "Splitting historical and forecast windows and computing derived indices.", "active"), | |
| gr.update(value="") | |
| ) | |
| normalized, error = normalize_weather_data(raw_weather, lat, lon, client_date) | |
| if error: | |
| failed_step = state.state | |
| state.is_analyzing = False | |
| state.state = STATE_ERROR | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, error), | |
| format_progress(failed_step, error=True), "", | |
| render_placeholder("Analysis failed", error, "error"), | |
| gr.update(value="") | |
| ) | |
| return | |
| state.state = STATE_GENERATING_INSIGHT | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_GENERATING_INSIGHT), | |
| format_progress(STATE_GENERATING_INSIGHT), | |
| "", | |
| render_placeholder("Generating weather insight", "Gemini is interpreting the data for risks and recommendations.", "active"), | |
| gr.update(value="") | |
| ) | |
| payload = build_llm_payload(normalized, lat, lon, client_date) | |
| llm_output, error = call_gemini(payload) | |
| if error: | |
| failed_step = state.state | |
| state.is_analyzing = False | |
| state.state = STATE_ERROR | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, error), | |
| format_progress(failed_step, error=True), "", | |
| render_placeholder("Analysis failed", error, "error"), | |
| gr.update(value="") | |
| ) | |
| return | |
| state.state = STATE_VALIDATING_OUTPUT | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_VALIDATING_OUTPUT), | |
| format_progress(STATE_VALIDATING_OUTPUT), | |
| "", | |
| render_placeholder("Validating analysis", "Checking the response against the expected schema.", "active"), | |
| gr.update(value="") | |
| ) | |
| valid, error = validate_llm_output(llm_output) | |
| if not valid: | |
| failed_step = state.state | |
| state.is_analyzing = False | |
| state.state = STATE_ERROR | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=True), gr.update(interactive=True), | |
| format_status(STATE_ERROR, error), | |
| format_progress(failed_step, error=True), "", | |
| render_placeholder("Analysis failed", error, "error"), | |
| gr.update(value="") | |
| ) | |
| return | |
| state.state = STATE_COMPLETED | |
| timezone_str = raw_weather.get("timezone", "UTC") | |
| location_date = normalized.get("meta", {}).get("location_date", client_date) | |
| # SINGLE ENTRY: directly overwrite state.cache | |
| cache_entry = { | |
| "location": state.location, | |
| "client_date": client_date, | |
| "location_date": location_date, | |
| "location_timezone": timezone_str, | |
| "client_timezone": state.client_timezone or "UTC", | |
| "field_context": { | |
| "crop_type": crop, | |
| "phenology_phase": phenology, | |
| "field_notes": notes, | |
| "current_concern": current_concern, | |
| }, | |
| "raw_weather": raw_weather, | |
| "analysis": llm_output, | |
| "created_at": datetime.now(timezone.utc).isoformat() | |
| } | |
| state.cache = cache_entry | |
| state.is_analyzing = False | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_COMPLETED), | |
| format_progress(STATE_COMPLETED), | |
| format_cache_info(cache_entry), | |
| render_analysis(llm_output), | |
| json.dumps(cache_entry) # cache_out: save single entry to browser | |
| ) | |
| state.state = STATE_LOCKED_TODAY | |
| yield ( | |
| state.to_dict(), | |
| gr.update(interactive=False), gr.update(interactive=False), | |
| format_status(STATE_LOCKED_TODAY, "Today's analysis complete. Return tomorrow for a new analysis."), | |
| "", | |
| format_cache_info(cache_entry), | |
| render_analysis(llm_output), | |
| json.dumps(cache_entry) # cache_out: save single entry to browser | |
| ) | |
| # ------------------------------------------------------------------ | |
| # GRADIO UI (Gradio 6.x compatible) | |
| # ------------------------------------------------------------------ | |
| THEME = gr.themes.Base( | |
| font=[gr.themes.GoogleFont("IBM Plex Sans"), "sans-serif"], | |
| font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"], | |
| ).set( | |
| body_background_fill="#0B1B22", | |
| body_background_fill_dark="#0B1B22", | |
| body_text_color="#E7F1F0", | |
| body_text_color_dark="#E7F1F0", | |
| body_text_color_subdued="#7E9CA3", | |
| body_text_color_subdued_dark="#7E9CA3", | |
| background_fill_primary="#122631", | |
| background_fill_primary_dark="#122631", | |
| background_fill_secondary="#0F2229", | |
| background_fill_secondary_dark="#0F2229", | |
| border_color_primary="#23414F", | |
| border_color_primary_dark="#23414F", | |
| block_background_fill="#122631", | |
| block_background_fill_dark="#122631", | |
| block_border_color="#23414F", | |
| block_border_color_dark="#23414F", | |
| block_label_text_color="#7E9CA3", | |
| block_label_text_color_dark="#7E9CA3", | |
| block_label_background_fill="#122631", | |
| block_label_background_fill_dark="#122631", | |
| block_title_text_color="#E7F1F0", | |
| block_title_text_color_dark="#E7F1F0", | |
| panel_background_fill="#0F2229", | |
| panel_background_fill_dark="#0F2229", | |
| panel_border_color="#23414F", | |
| panel_border_color_dark="#23414F", | |
| input_background_fill="#0F2229", | |
| input_background_fill_dark="#0F2229", | |
| input_border_color="#23414F", | |
| input_border_color_dark="#23414F", | |
| input_border_color_focus="#E8A33D", | |
| input_border_color_focus_dark="#E8A33D", | |
| button_primary_background_fill="#E8A33D", | |
| button_primary_background_fill_dark="#E8A33D", | |
| button_primary_background_fill_hover="#F2B457", | |
| button_primary_background_fill_hover_dark="#F2B457", | |
| button_primary_text_color="#0B1B22", | |
| button_primary_text_color_dark="#0B1B22", | |
| button_primary_border_color="#E8A33D", | |
| button_primary_border_color_dark="#E8A33D", | |
| button_secondary_background_fill="#16303D", | |
| button_secondary_background_fill_dark="#16303D", | |
| button_secondary_background_fill_hover="#1B3945", | |
| button_secondary_background_fill_hover_dark="#1B3945", | |
| button_secondary_text_color="#E7F1F0", | |
| button_secondary_text_color_dark="#E7F1F0", | |
| button_secondary_border_color="#23414F", | |
| button_secondary_border_color_dark="#23414F", | |
| error_background_fill="#2A1714", | |
| error_background_fill_dark="#2A1714", | |
| error_border_color="#E2604F", | |
| error_border_color_dark="#E2604F", | |
| ) | |
| # ------------------------------------------------------------------ | |
| # EXTERNAL ASSETS | |
| # ------------------------------------------------------------------ | |
| CSS = load_text("assets/styles.css") | |
| SPONSORS_RAW = load_json("assets/sponsors.json") | |
| SPONSORS_DATA = [(s["image"], s["name"]) for s in SPONSORS_RAW.get("sponsors", [])] | |
| SPONSOR_LINKS = [s["link"] for s in SPONSORS_RAW.get("sponsors", [])] | |
| SPONSOR_HTML = render_sponsors_html(SPONSORS_RAW.get("sponsors", [])) | |
| HEADER_HTML = HEADER_HTML = """ | |
| <div class="hero-banner"> | |
| <div class="app-header"> | |
| <div class="eyebrow">Field Telemetry · Weather Intelligence</div> | |
| <h1>DigiTanist/tanam</h1> | |
| <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> | |
| </div> | |
| <div class="farmer-counter"> | |
| <span class="farmer-counter-badge"> | |
| <span class="farmer-counter-dot"></span> | |
| <span><strong id="farmer-count">—</strong> petani sudah menganalisis hari ini</span> | |
| </span> | |
| </div> | |
| </div> | |
| """ | |
| FOOTER_HTML = """ | |
| <div class="app-footer">DATA · OPEN-METEO | ANALYSIS · GEMINI | ONE READ PER DAY</div> | |
| """ | |
| GA4_HEAD = """ | |
| <!-- Google tag (gtag.js) --> | |
| <script async src="https://www.googletagmanager.com/gtag/js?id=G-VWC31N398K"></script> | |
| <script> | |
| window.dataLayer = window.dataLayer || []; | |
| function gtag(){dataLayer.push(arguments);} | |
| gtag('js', new Date()); | |
| gtag('config', 'G-VWC31N398K'); | |
| </script> | |
| <script> | |
| function getTodayFarmerCount() { | |
| // ====================== PENGATURAN ANGKA ====================== | |
| const BASE_MIN = 220; | |
| const BASE_MAX = 380; | |
| const INCREASE_PER_10_MIN = 2; // Jumlah penambahan setiap 10 menit (bisa disesuaikan) | |
| // ============================================================== | |
| const now = new Date(); | |
| const today = now.getFullYear() + '-' + | |
| String(now.getMonth() + 1).padStart(2, '0') + '-' + | |
| String(now.getDate()).padStart(2, '0'); | |
| let seed = 0; | |
| for (let i = 0; i < today.length; i++) { | |
| seed = (seed * 31 + today.charCodeAt(i)) & 0xffff; | |
| } | |
| const base = BASE_MIN + (seed % (BASE_MAX - BASE_MIN + 1)); | |
| // 1. Hitung total menit yang sudah berlalu sejak pukul 00:00 hari ini | |
| const totalMinutes = (now.getHours() * 60) + now.getMinutes(); | |
| // 2. Hitung berapa banyak blok 10 menit yang sudah terlewati | |
| const blocksOf10Min = Math.floor(totalMinutes / 10); | |
| // 3. Hitung tambahan angka berdasarkan jumlah blok 10 menit | |
| const extra = blocksOf10Min * INCREASE_PER_10_MIN; | |
| return base + extra; | |
| } | |
| function animateCount(el, target, duration = 3300) { | |
| const start = Math.max(0, target - Math.floor(target * 0.15)); // mulai dari ~85% | |
| const startTime = performance.now(); | |
| function tick(now) { | |
| const progress = Math.min((now - startTime) / duration, 1); | |
| const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic | |
| el.innerText = Math.round(start + (target - start) * eased).toLocaleString("id-ID"); | |
| if (progress < 1) requestAnimationFrame(tick); | |
| } | |
| requestAnimationFrame(tick); | |
| } | |
| function updateFarmerCount() { | |
| const el = document.getElementById("farmer-count"); | |
| if (el) { | |
| animateCount(el, getTodayFarmerCount()); | |
| return true; // berhasil | |
| } | |
| return false; // elemen belum ada | |
| } | |
| // Coba terus sampai elemen muncul (maksimal 10 detik) | |
| function tryUpdateCounter(attempts = 0) { | |
| if (updateFarmerCount() || attempts > 20) return; | |
| setTimeout(() => tryUpdateCounter(attempts + 1), 500); | |
| } | |
| // Mulai mencoba segera | |
| tryUpdateCounter(); | |
| // Update rutin setiap 5 menit | |
| setInterval(updateFarmerCount, 5 * 60 * 1000); | |
| </script> | |
| """ | |
| with gr.Blocks(title="Space Weather", head=GA4_HEAD) as demo: | |
| gr.HTML(HEADER_HTML) | |
| app_state = gr.State({}) | |
| gps_data = gr.Textbox(visible=False, elem_id="gps_data_input") | |
| cache_in = gr.Textbox(visible=False, elem_id="browser_cache_in") | |
| cache_out = gr.Textbox(visible=False, elem_id="browser_cache_out") | |
| weather_json = gr.Textbox(visible=False, elem_id="weather_json_input") | |
| with gr.Row(elem_classes=["main-row"]): | |
| with gr.Column(scale=1, min_width=320, elem_classes=["rail-col"]): | |
| with gr.Column(elem_classes=["console-panel"]): | |
| gr.HTML('<div class="panel-eyebrow">Location</div>') | |
| lat_input = gr.Number( | |
| label="Latitude", | |
| precision=6, | |
| value=0, | |
| info="-90 to 90" | |
| ) | |
| lon_input = gr.Number( | |
| label="Longitude", | |
| precision=6, | |
| value=0, | |
| info="-180 to 180" | |
| ) | |
| crop_input = gr.Textbox( | |
| label="Jenis Tanaman", | |
| placeholder="Contoh: Padi, Jagung, Cabai", | |
| lines=1 | |
| ) | |
| phenology_input = gr.Textbox( | |
| label="Fase Fenologi", | |
| placeholder="Contoh: Vegetatif, Pembungaan, Pematangan", | |
| lines=1 | |
| ) | |
| notes_input = gr.Textbox( | |
| label="Catatan Lapangan", | |
| placeholder="Contoh: Ada genangan air, gejala serangan hama ringan", | |
| lines=2 | |
| ) | |
| current_concern_input = gr.Textbox( | |
| label="Current Concern", | |
| placeholder="Contoh: Khawatir kekurangan air atau risiko penyakit jamur", | |
| info="Opsional. Masukkan kondisi atau pertanyaan yang ingin diperiksa berdasarkan cuaca.", | |
| lines=2 | |
| ) | |
| with gr.Row(elem_classes=["btn-row"]): | |
| get_loc_btn = gr.Button("Ambil lokasi dari GPS perangkat", variant="secondary", size="sm") | |
| analyze_btn = gr.Button("Analyze", variant="primary", size="sm", interactive=False) | |
| with gr.Column(): | |
| status_html = gr.HTML("") | |
| step_html = gr.HTML("") | |
| cache_html = gr.HTML("") | |
| with gr.Column(scale=2, elem_classes=["content-col"]): | |
| # Placeholder awal sebelum ada analisis atau saat loading | |
| initial_placeholder = gr.HTML(render_placeholder("Initializing", "Loading today's status...")) | |
| # Tombol View Insight (awalnya disembunyikan) | |
| view_insight_btn = gr.Button("View Insight", variant="primary", visible=False, size="lg") | |
| # Kontainer Tab dibungkus Column dan disembunyikan secara default (visible=False) | |
| with gr.Column(visible=False) as tabs_container: | |
| with gr.Tabs(): | |
| with gr.TabItem("Summary"): | |
| summary_html = gr.HTML() | |
| with gr.TabItem("Historical"): | |
| historical_html = gr.HTML() | |
| with gr.TabItem("Forecast"): | |
| forecast_html = gr.HTML() | |
| with gr.TabItem("Risks"): | |
| risks_html = gr.HTML() | |
| with gr.TabItem("Recommendations"): | |
| recommendations_html = gr.HTML() | |
| # Tombol download data mentah (JSON): tersembunyi, muncul | |
| # bersamaan dengan tabs insight saat "View Insight" diklik. | |
| download_data_btn = gr.DownloadButton( | |
| "Download Data Analisis (TXT)", | |
| visible=False, | |
| size="md", | |
| variant="secondary", | |
| elem_classes=["download-data-btn"] | |
| ) | |
| # Sponsor section (paling bawah) | |
| gr.HTML('<div style="height: 16px;"></div>') | |
| with gr.Column(elem_classes=["console-panel"]): | |
| gr.HTML('<div class="panel-eyebrow" style="padding: 14px 14px 6px;">Supported By</div>') | |
| gr.HTML(SPONSOR_HTML) | |
| gr.HTML(FOOTER_HTML) | |
| # 1. Page load: read localStorage into hidden textbox | |
| # 1. Page load: baca localStorage + waktu browser, kirim sebagai composite JSON | |
| demo.load( | |
| fn=None, | |
| js="""() => { | |
| let cache = '{}'; | |
| try { | |
| cache = localStorage.getItem('space_weather_cache') || '{}'; | |
| } catch (e) { | |
| cache = '{}'; | |
| } | |
| const now = new Date(); | |
| const browserDate = now.getFullYear() + '-' + | |
| String(now.getMonth() + 1).padStart(2, '0') + '-' + | |
| String(now.getDate()).padStart(2, '0'); | |
| const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; | |
| return JSON.stringify({ | |
| cache: cache, | |
| browser_date: browserDate, | |
| browser_timezone: browserTz | |
| }); | |
| }""", | |
| outputs=cache_in | |
| ) | |
| # 2. When cache_in changes, initialize app from browser cache | |
| cache_in.change( | |
| fn=init_app, | |
| inputs=[cache_in], | |
| outputs=[ | |
| app_state, lat_input, lon_input, get_loc_btn, analyze_btn, | |
| status_html, step_html, cache_html, | |
| initial_placeholder, view_insight_btn, tabs_container, | |
| summary_html, historical_html, forecast_html, risks_html, recommendations_html, | |
| cache_out | |
| ] | |
| ) | |
| # 3. Get Current Location -> JS geolocation -> hidden textbox | |
| get_loc_btn.click( | |
| fn=None, | |
| js=""" | |
| async () => { | |
| return new Promise((resolve) => { | |
| if (navigator.geolocation) { | |
| navigator.geolocation.getCurrentPosition( | |
| (position) => { | |
| resolve(JSON.stringify({ | |
| latitude: position.coords.latitude, | |
| longitude: position.coords.longitude, | |
| source: "gps", | |
| accuracy_m: position.coords.accuracy | |
| })); | |
| }, | |
| async (error) => { | |
| try { | |
| const resp = await fetch('https://ipwho.is/'); | |
| const data = await resp.json(); | |
| if (data.success) { | |
| resolve(JSON.stringify({ | |
| latitude: data.latitude, | |
| longitude: data.longitude, | |
| source: "ip", | |
| accuracy_m: null | |
| })); | |
| } else { | |
| resolve(JSON.stringify({ | |
| error: "Unable to determine your location. Please enter coordinates manually." | |
| })); | |
| } | |
| } catch (e) { | |
| resolve(JSON.stringify({ | |
| error: "Unable to determine your location. Please enter coordinates manually." | |
| })); | |
| } | |
| }, | |
| {timeout: 10000, maximumAge: 60000} | |
| ); | |
| } else { | |
| fetch('https://ipwho.is/') | |
| .then(r => r.json()) | |
| .then(data => { | |
| if (data.success) { | |
| resolve(JSON.stringify({ | |
| latitude: data.latitude, | |
| longitude: data.longitude, | |
| source: "ip", | |
| accuracy_m: null | |
| })); | |
| } else { | |
| resolve(JSON.stringify({ | |
| error: "Unable to determine your location. Please enter coordinates manually." | |
| })); | |
| } | |
| }) | |
| .catch(() => { | |
| resolve(JSON.stringify({ | |
| error: "Unable to determine your location. Please enter coordinates manually." | |
| })); | |
| }); | |
| } | |
| }); | |
| } | |
| """, | |
| outputs=gps_data | |
| ) | |
| gps_data.change( | |
| fn=handle_gps_result, | |
| inputs=[gps_data, app_state], | |
| outputs=[app_state, lat_input, lon_input, status_html] | |
| ) | |
| analyze_btn.click( | |
| fn=None, | |
| js="""async (lat, lon) => { | |
| if (typeof gtag === 'function') { | |
| gtag('event', 'analyze_click', { | |
| 'event_category': 'engagement', | |
| 'latitude': lat, | |
| 'longitude': lon | |
| }); | |
| } | |
| if (lat === null || lon === null || isNaN(lat) || isNaN(lon)) { | |
| return JSON.stringify({ error: "Invalid coordinates. Please enter a valid latitude and longitude." }); | |
| } | |
| const variables = [ | |
| "temperature_2m_max", "temperature_2m_min", "rain_sum", "precipitation_sum", | |
| "wind_gusts_10m_max", "shortwave_radiation_sum", "temperature_2m_mean", | |
| "cloud_cover_mean", "et0_fao_evapotranspiration", | |
| "growing_degree_days_base_0_limit_50", "leaf_wetness_probability_mean", | |
| "vapour_pressure_deficit_max" | |
| ].join(","); | |
| const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&daily=${variables}&timezone=auto&past_days=7&forecast_days=7`; | |
| try { | |
| const resp = await fetch(url); | |
| if (!resp.ok) { | |
| return JSON.stringify({ error: `Weather data unavailable. Please try again later. (HTTP ${resp.status})` }); | |
| } | |
| const data = await resp.json(); | |
| return JSON.stringify(data); | |
| } catch (e) { | |
| return JSON.stringify({ error: "Weather data unavailable. Network error. Please try again later." }); | |
| } | |
| }""", | |
| inputs=[lat_input, lon_input], | |
| outputs=[weather_json] | |
| ) | |
| weather_json.change( | |
| fn=handle_weather_and_analyze, | |
| inputs=[weather_json, lat_input, lon_input, crop_input, phenology_input, notes_input, current_concern_input, app_state], | |
| outputs=[ | |
| app_state, get_loc_btn, analyze_btn, | |
| status_html, step_html, cache_html, | |
| initial_placeholder, view_insight_btn, tabs_container, | |
| summary_html, historical_html, forecast_html, risks_html, recommendations_html, | |
| cache_out | |
| ] | |
| ) | |
| lat_input.change( | |
| fn=validate_coordinates_ui, | |
| inputs=[lat_input, lon_input, app_state], | |
| outputs=[analyze_btn] | |
| ) | |
| lon_input.change( | |
| fn=validate_coordinates_ui, | |
| inputs=[lat_input, lon_input, app_state], | |
| outputs=[analyze_btn] | |
| ) | |
| def show_insight_view(state_dict): | |
| # Langkah 1: tampilkan tabs + tombol download TERLEBIH DAHULU, | |
| # tapi tanpa value/file dulu (href kosong). Ini memastikan | |
| # elemen tombolnya sudah ter-mount & visible di DOM sebelum | |
| # kita isi hrefnya di langkah 2 (.then()). Kalau visible=True | |
| # dan value diisi dalam SATU update yang sama, kadang Gradio | |
| # sempat me-render ulang elemennya sehingga href belum | |
| # "nyantol" saat klik pertama -> baru berfungsi di klik kedua. | |
| state = AppState.from_dict(state_dict) | |
| has_cache = bool(state.cache) | |
| return ( | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| gr.update(visible=has_cache, value=None) | |
| ) | |
| def prepare_download_file(state_dict): | |
| # Langkah 2: baru sekarang isi value (href) tombol, SETELAH | |
| # tombolnya sudah pasti ter-mount & visible dari langkah 1. | |
| state = AppState.from_dict(state_dict) | |
| export_path = build_export_file(state.cache) | |
| return gr.update(value=export_path, visible=bool(export_path)) | |
| view_insight_btn.click( | |
| fn=show_insight_view, | |
| inputs=[app_state], | |
| js=f"""() => {{ | |
| if (typeof gtag === 'function') {{ | |
| gtag('event', 'view_insight_click', {{ | |
| 'event_category': 'engagement' | |
| }}); | |
| }} | |
| window.open('{SHOPEE_LINK}', '_blank'); | |
| }}""", | |
| outputs=[view_insight_btn, tabs_container, download_data_btn] | |
| ).then( | |
| fn=prepare_download_file, | |
| inputs=[app_state], | |
| outputs=[download_data_btn] | |
| ) | |
| # 4. When cache_out changes, save to localStorage (overwrite old data) | |
| cache_out.change( | |
| fn=None, | |
| js="""(data) => { | |
| if (data && data !== '{}' && data !== '') { | |
| try { | |
| // Always overwrite — never accumulate | |
| localStorage.setItem('space_weather_cache', data); | |
| } catch (e) { | |
| console.error('Failed to save cache to localStorage:', e); | |
| } | |
| } | |
| return []; | |
| }""", | |
| inputs=[cache_out] | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| theme=THEME, | |
| css=CSS, | |
| allowed_paths=["assets"] | |
| ) | |