Backend & API · pegangan Peran A, B, C

Satu API, dua job, satu agen

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.

01 · Layanan

Siapa memanggil siapa

Scheduler memicu job ingest dan daily; job menulis ke Firestore dan Storage lalu memanggil endpoint internal API; browser memanggil API lewat Firebase Hosting Cloud Scheduler:05 tiap jam · 06.00 job asapify-ingestFIRMS · insiden · ToPeCAl job asapify-dailygrid risiko · sitrep Firestoreincidents · alerts · … Cloud StorageGeoJSON · thumbnail Earth Enginedipanggil oleh job ingest asapify-apiFastAPI + agen ADK→ Gemini BrowserFirebase Hosting/api/** → Cloud Run POST /internal/agent/draft (OIDC) /api/v1
service

asapify-api

FastAPI. Tiga kelompok endpoint: publik, operator, internal. Agen ADK berjalan di dalam service ini.

job · tiap jam

asapify-ingest

Tarik FIRMS, kelompokkan insiden, tentukan level, jalankan ToPeCAl, minta draf ke agen.

job · 06.00 WIB

asapify-daily

Hitung grid risiko, tulis GeoJSON ke bucket publik, minta sitrep ke agen.

02 · Konvensi

Aturan yang berlaku di semua endpoint

Base path
/api/v1. Di produksi lewat Firebase Hosting (satu origin dengan web). Saat pengembangan, pakai URL Cloud Run langsung.
Format
JSON UTF-8. Data peta dalam GeoJSON (RFC 7946), urutan koordinat [lon, lat].
Waktu
ISO 8601 dengan zona. Simpan dalam UTC, tampilkan dalam WIB.
ID insiden
{PROV}-{YYYYMMDD}-{nnn}, contoh KT-20260912-042
Auth publik
tanpa token
Auth operator
Authorization: Bearer <Firebase ID token>, wajib punya custom claim role = "operator"
Auth internal
token OIDC Google dari service account sa-jobs, audience = URL service. Diverifikasi di aplikasi.
Replay
parameter as_of (ISO 8601) di semua endpoint publik. Respons menyertakan header X-Asapify-As-Of.
Cache
publik: Cache-Control: public, max-age=300. Operator dan internal: no-store.
Galat
{"error": {"code": "...", "message": "..."}} dengan status HTTP yang sesuai
03 · Daftar endpoint

Tujuh publik, enam operator, dua internal

MetodePath (setelah /api/v1)AuthFungsiRespons
Publik
GET/healthpublikcek hidup untuk uptime check{"ok": true}
GET/metapublikprovinsi, waktu ingest terakhir, status replayobjek meta
GET/incidents?since=24h&level=&as_of=publikinsiden untuk petaGeoJSON FeatureCollection
GET/incidents/{id}publikdetail insiden + alert yang sudah disetujui + titik deteksiobjek insiden
GET/risk?date=publiktautan GeoJSON grid risiko untuk tanggal itu{"date", "url", "generated_at"}
GET/alerts?since=24hpublikdaftar alert berstatus approvedarray alert
GET/sitreps/{date}publiksitrep yang sudah disetujuiobjek sitrep
Operator
GET/operator/queue?status=draftoperatorantrean, urut level lalu waktuarray ringkasan alert
GET/operator/alerts/{id}operatordraf lengkap + hasil validator + jejak toolsobjek alert
PATCH/operator/alerts/{id}operatorubah pesan, tindakan, atau level (level wajib dengan override_reason)objek alert terbaru
POST/operator/alerts/{id}/approveoperatorterbitkan ke publikobjek alert, status: approved
POST/operator/alerts/{id}/rejectoperatortolak, body {"reason"} wajibobjek alert, status: rejected
POST/operator/alerts/{id}/regenerateoperatorjalankan agen lagi untuk insiden yang samaobjek alert baru
Internal (hanya dari job)
POST/internal/agent/draftOIDC sa-jobsbody {"incident_id"}. Agen menyusun draf, validator memeriksa, hasil disimpan{"alert_id", "status", "attempts"}
POST/internal/agent/sitrepOIDC sa-jobsbody {"date"}. Agen menyusun sitrep harian{"sitrep_id", "status"}
Sitrep juga melewati persetujuan operator. Endpoint-nya mengikuti pola alert: /operator/sitreps/{id}/approve.
04 · Contoh request dan respons

Bentuk data yang disepakati frontend dan backend

Semua data di bawah adalah contoh fiktif.

GET /api/v1/incidents?since=24h200
{
  "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"
    }
  }]
}
PATCH /api/v1/operator/alerts/a_7f3crequest
{
  "message_id": "Titik api berulang di lahan gambut …",
  "level": "AWAS",
  "override_reason": "Laporan warga: asap tebal sejak pagi"
}
POST /api/v1/operator/alerts/a_7f3c/approve200
{
  "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"
}
Galat · 409response
{ "error": { "code": "ALREADY_DECIDED",
  "message": "Alert sudah disetujui oleh operator lain." } }
POST /api/v1/internal/agent/draft200
{ "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"] }
05 · Model data Firestore

Enam koleksi

Hanya service account backend yang boleh membaca dan menulis. Aturan Firestore menolak semua akses dari browser.

incidents/{id}
FieldTipeIsi
provincestringKT
centroidgeopointtitik pusat insiden
detectionsarray{lat, lon, acq_at, sensor, frp, confidence}
n_detectionsnumberjumlah dalam 24 jam
on_peat, khg_namebool, stringhasil irisan dengan layer gambut
risk_classstringkelas risiko sel tempat insiden
nearest_villagemap{name, km}
level_rulestringPANTAU / WASPADA / SIAGA / AWAS
verificationmap{status, sensor, image_date, class_counts, thumb_url}
alert_idstringalert terbaru
first_seen, updated_attimestamp
alerts/{id}
FieldTipeIsi
incident_idstring
statusstringdraft / needs_review / approved / rejected
level, level_sourcestringrule atau operator_override
title, reasonsstring, arraydari agen
actionsmap{agency, community, residents}
message_id, message_enstringmaks. 400 karakter
validatormap{schema, level_match, numbers_match, errors[]}
tool_callsarraynama tools + ringkasan argumen
model, attemptsstring, numbernama model Gemini, jumlah percobaan
decided_by, decided_at, reasonstring, timestampkeputusan operator
Koleksi lainIsi
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
Indeks komposit: alerts (status ASC, level DESC, created_at DESC) dan incidents (province ASC, updated_at DESC).
06 · Agen ADK

Tools, skema, dan validator

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.

Definisi agenapi/agent/agent.py
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],
)
Skema dan toolsapi/agent/tools.py
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": [...]}"""
    ...

Validator

  • Lolos skema Pydantic
  • Level sama dengan level_rule
  • Setiap angka di teks ada di hasil tools
  • Tidak ada kata "pasti" bila belum terverifikasi

Percobaan ulang

  • Maksimal 2 kali panggil save_alert_draft yang gagal
  • Setelah itu status needs_review
  • Timeout agen 60 detik per insiden

Model dan setelan

  • Gemini Flash terbaru lewat Vertex AI (nama model di env GEMINI_MODEL)
  • temperature 0,2
  • Dipanggil hanya untuk insiden baru atau yang naik level
07 · Job

Langkah di dalam setiap job

Kedua job menerima argumen --as-of untuk mode replay. Tanpa argumen, job memakai waktu sekarang.

asapify-ingest · tiap jam
  1. Tarik FIRMS area API untuk kotak provinsi, 1 hari terakhir, dari tiga satelit VIIRS.
  2. Buat detection_key = hash(lat, lon, acq_at, sensor). Lewati yang sudah ada.
  3. Kelompokkan deteksi 24 jam terakhir dengan DBSCAN (radius 1 km, jarak haversine).
  4. Iris dengan layer gambut dan cari desa terdekat.
  5. Hitung level_rule dengan fungsi aturan (tabel di halaman Rencana).
  6. Untuk insiden Siaga ke atas: cari Landsat ±1 hari, jalankan ToPeCAl, simpan thumbnail.
  7. Tulis insiden ke Firestore.
  8. Untuk insiden baru atau yang naik level: panggil POST /internal/agent/draft.
  9. Tulis ringkasan ke runs.
asapify-daily · 06.00 WIB
  1. Buat atau muat grid 5 km provinsi.
  2. Ambil cuaca Open-Meteo per titik 0,25°: hari tanpa hujan, angin, prakiraan 3 hari.
  3. Hitung skor risiko per sel (rumus di halaman Rencana).
  4. Tulis risk/{date}.geojson ke bucket publik, catat di risk_days.
  5. Panggil POST /internal/agent/sitrep.
  6. Tulis ringkasan ke runs.
Fungsi aturan, ToPeCAl, dan skor risiko ditaruh di jobs/lib/ sebagai fungsi murni dengan unit test, supaya backtest dan produksi memakai kode yang sama.
08 · Struktur repo

Satu repo untuk semua komponen

Monorepoasapify/
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
09 · Galat & batas

Yang terjadi saat sesuatu gagal

KejadianPerilaku sistem
FIRMS tidak meresponsCoba ulang 3 kali dengan jeda bertambah, lalu catat run gagal. Alert monitoring menyala setelah 2 jam tanpa run sukses.
Batas transaksi MAP_KEY FIRMSSatu permintaan area per sumber per jam. Jangan memanggil FIRMS dari API.
Earth Engine gagal atau lambatInsiden tetap disimpan dengan verification.status = "error". Level tetap dari aturan.
Gemini gagal atau timeoutAlert berstatus needs_review tanpa teks agen. Operator bisa menekan Buat ulang.
Dua operator menyetujui bersamaanTransaksi Firestore. Yang kedua mendapat 409 ALREADY_DECIDED.
Job terpicu dua kaliAman karena detection_key dan ID insiden deterministik.