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