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