Backend terdiri dari satu service Cloud Run asapify-api (FastAPI + agen ADK) dan dua Cloud Run job. Semua data disimpan di Firestore dan Cloud Storage. Browser tidak pernah membaca Firestore langsung; semuanya lewat API.
asapify-apiFastAPI. Tiga kelompok endpoint: publik, operator, internal. Agen ADK berjalan di dalam service ini.
asapify-ingestTarik FIRMS, kelompokkan insiden, tentukan level, jalankan ToPeCAl, minta draf ke agen.
asapify-dailyHitung grid risiko, tulis GeoJSON ke bucket publik, minta sitrep ke agen.
/api/v1. Di produksi lewat Firebase Hosting (satu origin dengan web). Saat pengembangan, pakai URL Cloud Run langsung.[lon, lat].{PROV}-{YYYYMMDD}-{nnn}, contoh KT-20260912-042Authorization: Bearer <Firebase ID token>, wajib punya custom claim role = "operator"sa-jobs, audience = URL service. Diverifikasi di aplikasi.as_of (ISO 8601) di semua endpoint publik. Respons menyertakan header X-Asapify-As-Of.Cache-Control: public, max-age=300. Operator dan internal: no-store.{"error": {"code": "...", "message": "..."}} dengan status HTTP yang sesuai| Metode | Path (setelah /api/v1) | Auth | Fungsi | Respons |
|---|---|---|---|---|
| Publik | ||||
| GET | /health | publik | cek hidup untuk uptime check | {"ok": true} |
| GET | /meta | publik | provinsi, waktu ingest terakhir, status replay | objek meta |
| GET | /incidents?since=24h&level=&as_of= | publik | insiden untuk peta | GeoJSON FeatureCollection |
| GET | /incidents/{id} | publik | detail insiden + alert yang sudah disetujui + titik deteksi | objek insiden |
| GET | /risk?date= | publik | tautan GeoJSON grid risiko untuk tanggal itu | {"date", "url", "generated_at"} |
| GET | /alerts?since=24h | publik | daftar alert berstatus approved | array alert |
| GET | /sitreps/{date} | publik | sitrep yang sudah disetujui | objek sitrep |
| Operator | ||||
| GET | /operator/queue?status=draft | operator | antrean, urut level lalu waktu | array ringkasan alert |
| GET | /operator/alerts/{id} | operator | draf lengkap + hasil validator + jejak tools | objek alert |
| PATCH | /operator/alerts/{id} | operator | ubah pesan, tindakan, atau level (level wajib dengan override_reason) | objek alert terbaru |
| POST | /operator/alerts/{id}/approve | operator | terbitkan ke publik | objek alert, status: approved |
| POST | /operator/alerts/{id}/reject | operator | tolak, body {"reason"} wajib | objek alert, status: rejected |
| POST | /operator/alerts/{id}/regenerate | operator | jalankan agen lagi untuk insiden yang sama | objek alert baru |
| Internal (hanya dari job) | ||||
| POST | /internal/agent/draft | OIDC sa-jobs | body {"incident_id"}. Agen menyusun draf, validator memeriksa, hasil disimpan | {"alert_id", "status", "attempts"} |
| POST | /internal/agent/sitrep | OIDC sa-jobs | body {"date"}. Agen menyusun sitrep harian | {"sitrep_id", "status"} |
Semua data di bawah adalah contoh fiktif.
{
"type": "FeatureCollection",
"as_of": "2026-09-12T10:05:00+07:00",
"features": [{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [113.9217, -2.2104] },
"properties": {
"id": "KT-20260912-042",
"level": "SIAGA",
"n_detections": 3,
"frp_max_mw": 18.4,
"on_peat": true,
"khg_name": "KHG contoh",
"nearest_village": { "name": "Desa contoh", "km": 3.2 },
"verification": { "status": "no_image" },
"alert_id": "a_7f3c",
"updated_at": "2026-09-12T09:58:00+07:00"
}
}]
}
{
"message_id": "Titik api berulang di lahan gambut …",
"level": "AWAS",
"override_reason": "Laporan warga: asap tebal sejak pagi"
}
{
"id": "a_7f3c",
"status": "approved",
"approved_by": "operator@contoh.id",
"approved_at": "2026-09-12T10:11:00+07:00",
"level": "AWAS",
"level_source": "operator_override"
}
{ "error": { "code": "ALREADY_DECIDED",
"message": "Alert sudah disetujui oleh operator lain." } }
{ "alert_id": "a_7f3c", "status": "draft", "attempts": 1,
"validator": { "schema": true, "level_match": true, "numbers_match": true },
"tool_calls": ["get_incident", "get_peat_context", "get_weather", "get_verification", "get_nearby_villages", "save_alert_draft"] }
Hanya service account backend yang boleh membaca dan menulis. Aturan Firestore menolak semua akses dari browser.
| Field | Tipe | Isi |
|---|---|---|
province | string | KT |
centroid | geopoint | titik pusat insiden |
detections | array | {lat, lon, acq_at, sensor, frp, confidence} |
n_detections | number | jumlah dalam 24 jam |
on_peat, khg_name | bool, string | hasil irisan dengan layer gambut |
risk_class | string | kelas risiko sel tempat insiden |
nearest_village | map | {name, km} |
level_rule | string | PANTAU / WASPADA / SIAGA / AWAS |
verification | map | {status, sensor, image_date, class_counts, thumb_url} |
alert_id | string | alert terbaru |
first_seen, updated_at | timestamp |
| Field | Tipe | Isi |
|---|---|---|
incident_id | string | |
status | string | draft / needs_review / approved / rejected |
level, level_source | string | rule atau operator_override |
title, reasons | string, array | dari agen |
actions | map | {agency, community, residents} |
message_id, message_en | string | maks. 400 karakter |
validator | map | {schema, level_match, numbers_match, errors[]} |
tool_calls | array | nama tools + ringkasan argumen |
model, attempts | string, number | nama model Gemini, jumlah percobaan |
decided_by, decided_at, reason | string, timestamp | keputusan operator |
| Koleksi lain | Isi |
|---|---|
sitreps/{date} | teks ringkasan, jumlah per level, status persetujuan |
risk_days/{date} | URL GeoJSON di bucket publik, jumlah sel per kelas, waktu dibuat |
runs/{run_id} | log tiap eksekusi job: mulai, selesai, jumlah hotspot, insiden baru, galat |
audit_log/{auto} | setiap aksi operator: siapa, apa, kapan, nilai lama dan baru |
Agen tidak memakai output_schema. Sebagai gantinya, agen wajib memanggil save_alert_draft. Tool ini memvalidasi draf; kalau gagal, pesan galatnya dikembalikan ke agen supaya bisa memperbaiki sendiri, maksimal dua kali.
import os
from google.adk.agents import Agent
from .tools import (get_incident, get_peat_context, get_weather,
get_verification, get_nearby_villages, save_alert_draft)
INSTRUCTION = """
You write peatland fire alerts for responders in Indonesia.
1. Call get_incident first, then the other get_* tools.
2. Keep the level exactly as returned by get_incident.
3. Every number you write must come from a tool result.
If data is missing, write "not available".
4. Never say a fire is certain unless verification
status is "fire_detected".
5. Write short, plain sentences. Max 60 words per message.
6. Finish by calling save_alert_draft. If it returns errors,
fix them and call it again.
"""
alert_agent = Agent(
name="asapify_alert_agent",
model=os.environ["GEMINI_MODEL"],
instruction=INSTRUCTION,
tools=[get_incident, get_peat_context, get_weather,
get_verification, get_nearby_villages, save_alert_draft],
)
from typing import Literal
from pydantic import BaseModel, Field
class Actions(BaseModel):
agency: str
community: str
residents: str
class AlertDraft(BaseModel):
incident_id: str
level: Literal["WASPADA", "SIAGA", "AWAS"]
title: str = Field(max_length=90)
reasons: list[str] = Field(min_length=2, max_length=5)
verification: str
actions: Actions
message_id: str = Field(max_length=400)
message_en: str = Field(max_length=400)
def get_incident(incident_id: str) -> dict: ...
def get_peat_context(lat: float, lon: float) -> dict: ...
def get_weather(lat: float, lon: float) -> dict: ...
def get_verification(incident_id: str) -> dict: ...
def get_nearby_villages(lat: float, lon: float,
radius_km: float = 5.0) -> dict: ...
def save_alert_draft(draft: dict) -> dict:
"""Validate and store the draft. Returns
{"ok": true, "alert_id": ...} or {"ok": false, "errors": [...]}"""
...
level_rulesave_alert_draft yang gagalneeds_reviewGEMINI_MODEL)Kedua job menerima argumen --as-of untuk mode replay. Tanpa argumen, job memakai waktu sekarang.
detection_key = hash(lat, lon, acq_at, sensor). Lewati yang sudah ada.level_rule dengan fungsi aturan (tabel di halaman Rencana).POST /internal/agent/draft.runs.risk/{date}.geojson ke bucket publik, catat di risk_days.POST /internal/agent/sitrep.runs.jobs/lib/ sebagai fungsi murni dengan unit test, supaya backtest dan produksi memakai kode yang sama.asapify/ ├─ api/ # Cloud Run service asapify-api │ ├─ main.py # FastAPI app, mount /api/v1 │ ├─ routers/ public.py, operator.py, internal.py │ ├─ auth.py # verifikasi Firebase ID token + OIDC internal │ ├─ agent/ agent.py, tools.py, validator.py │ └─ requirements.txt ├─ jobs/ # Cloud Run jobs │ ├─ ingest.py, daily.py │ ├─ lib/ firms.py, cluster.py, rules.py, topecal.py, risk.py, weather.py │ ├─ tests/ # unit test aturan, ToPeCAl, risiko │ └─ requirements.txt ├─ notebooks/ backtest.ipynb ├─ web/ # lihat halaman UI ├─ infra/ firestore.rules, firestore.indexes.json, cors.json, setup.sh └─ data/README.md # sumber dan lisensi layer gambut & desa
| Kejadian | Perilaku sistem |
|---|---|
| FIRMS tidak merespons | Coba ulang 3 kali dengan jeda bertambah, lalu catat run gagal. Alert monitoring menyala setelah 2 jam tanpa run sukses. |
| Batas transaksi MAP_KEY FIRMS | Satu permintaan area per sumber per jam. Jangan memanggil FIRMS dari API. |
| Earth Engine gagal atau lambat | Insiden tetap disimpan dengan verification.status = "error". Level tetap dari aturan. |
| Gemini gagal atau timeout | Alert berstatus needs_review tanpa teks agen. Operator bisa menekan Buat ulang. |
| Dua operator menyetujui bersamaan | Transaksi Firestore. Yang kedua mendapat 409 ALREADY_DECIDED. |
| Job terpicu dua kali | Aman karena detection_key dan ID insiden deterministik. |