WeatherData / app.py
FajarHidaa's picture
Upload app.py
1d0e569 verified
Raw
History Blame Contribute Delete
79 kB
"""
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
# ------------------------------------------------------------------
@dataclass
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,
}
@classmethod
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" &plusmn;{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", "&times;"
elif is_done:
cls, marker = "is-done", "&check;"
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">&check;</div>'
f'<div class="cache-card-body">'
f'<div class="cache-card-title">Today&rsquo;s analysis is ready</div>'
f'<div class="cache-card-meta">'
f'<span>{esc(client_date)}</span><span class="dot-sep">&middot;</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&nbsp;{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 &middot; 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 &middot; 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;">&#128197; {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 &middot; 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 &middot; OPEN-METEO&nbsp;&nbsp;|&nbsp;&nbsp;ANALYSIS &middot; GEMINI&nbsp;&nbsp;|&nbsp;&nbsp;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"]
)