Skip to content

10.7 Alert Harga Kondisional

Topologi: A — Agent tunggal Materi yang dipakai: 7.2 Signals & Signal Providers, 1.4Prasyarat: 10.6

Masalahnya

Laporan pukul 11.00 berguna, tapi permintaan yang sebenarnya paling sering muncul berbunyi begini:

"Kabari saya kalau BBCA menyentuh 123."

Pemicunya bukan waktu. Bisa terjadi jam 09.30, bisa tidak terjadi berminggu- minggu. Cron tidak bisa menjawab kebutuhan ini.

Ada juga masalah kedua yang lebih menyebalkan dari yang terlihat: begitu harga menyentuh 123, ia akan naik-turun di sekitar angka itu. Tanpa penanganan, pengguna menerima 40 notifikasi dalam 10 menit.

Keputusan arsitektur

Keputusan 1 — Siapa yang mengevaluasi kondisi: agent atau kode biasa?

Ini keputusan paling penting di kasus ini, dan yang paling sering salah.

Agent mengevaluasiKode biasa mengevaluasi
Biaya per pengecekan1 panggilan modelNol
Frekuensi wajarBeberapa kali sehariTiap 10 detik
KonsistensiBisa berbeda antar pemanggilanSelalu sama
Bisa diujiButuh scorerUnit test biasa
Cocok untukPenilaian kaburPerbandingan angka

Pilihan: kode biasa.

"Apakah 130 lebih besar atau sama dengan 123" bukan pertanyaan yang butuh kecerdasan. Menyerahkannya ke model berarti membayar panggilan LLM untuk operasi >=, dengan hasil yang bisa berbeda-beda.

Ini penerapan langsung aturan dari pengantar bagian ini: kalau salah langkah berarti kerugian uang, itu bukan pekerjaan agent.

Agent tetap dipakai — tapi hanya untuk yang memang butuh bahasa: menyusun pesan notifikasi setelah kondisi terpenuhi.

text
┌─────────────────────┐      ┌──────────────────────┐
│ Kode biasa          │      │ Agent                │
│ - polling harga     │ ───► │ - susun pesan yang   │
│ - evaluasi kondisi  │ picu │   enak dibaca        │
│ - cooldown          │      │ - beri konteks       │
└─────────────────────┘      └──────────────────────┘
   deterministik, gratis        1 panggilan, HANYA saat picu

Keputusan 2 — Bagaimana mencegah banjir notifikasi?

PendekatanCara kerjaKelemahan
Alert sekali pakaiNonaktif setelah dipicuPengguna harus mengaktifkan lagi
CooldownDiam N menit setelah dipicuBisa melewatkan pergerakan penting
HisteresisPerlu turun dulu ke batas bawahLebih rumit dijelaskan ke pengguna

Pilihan: cooldown, dengan alert tetap aktif.

Cooldown paling mudah dipahami pengguna ("kamu tidak akan dikabari lagi dalam 1 jam") dan paling sederhana diimplementasikan. Histeresis lebih benar secara teknis, tapi sulit dijelaskan di antarmuka.

Keputusan 3 — Polling atau webhook?

PollingWebhook penyedia
KetergantunganTidak adaPenyedia harus mendukung
LatensiSebesar intervalHampir seketika
BebanPanggilan API terus-menerusHanya saat ada perubahan
KerumitanRendahPerlu endpoint publik + verifikasi

Pilihan: polling, karena kebanyakan API harga saham retail tidak menyediakan webhook.

Inilah yang dimaksud signal provider di 7.2: komponen yang memantau sistem eksternal lewat polling atau webhook, lalu meneruskan peristiwa yang cocok ke thread agent.

Struktur direktori

text
alert-saham/
├── src/
│   ├── domain/
│   │   ├── alert.ts             ← evaluasi kondisi — MURNI, tanpa Mastra
│   │   └── alert.test.ts        ← unit test biasa, tanpa LLM
│   ├── infra/
│   │   ├── harga.ts             ← klien API harga
│   │   └── repo-alert.ts        ← simpan/baca alert dari DB
│   ├── worker/
│   │   └── pemantau.ts          ← loop polling
│   └── mastra/
│       ├── agents/
│       │   └── notifikasi-agent.ts
│       └── index.ts
└── package.json

Perhatikan alert.test.ts berdampingan dengan kode yang diujinya, dan tidak butuh API key sama sekali. Itu keuntungan langsung dari keputusan 1.

Direktori worker/ baru — ia proses yang berjalan terus, terpisah dari server HTTP.

Alur

Jalur picu: false adalah jalur yang paling sering diambil — dan ia tidak menyentuh model sama sekali. Itu inti efisiensi desain ini.

Implementasi

src/domain/alert.ts

ts
// Tidak ada Mastra, tidak ada LLM, tidak ada I/O. Murni fungsi.
export type Alert = {
  id: string
  ticker: string
  arah: 'naik' | 'turun'
  target: number
  status: 'aktif' | 'nonaktif'
  cooldownMs: number
  terakhirDipicu: number | null
}

export type HasilEvaluasi = {
  picu: boolean
  alasan: 'tercapai' | 'belum-tercapai' | 'cooldown' | 'alert-nonaktif'
}

export function evaluasiAlert(
  alert: Alert,
  harga: number,
  sekarang: number = Date.now(),
): HasilEvaluasi {
  if (alert.status !== 'aktif') {
    return { picu: false, alasan: 'alert-nonaktif' }
  }

  const tercapai =
    alert.arah === 'naik' ? harga >= alert.target : harga <= alert.target

  if (!tercapai) {
    return { picu: false, alasan: 'belum-tercapai' }
  }

  if (alert.terakhirDipicu && sekarang - alert.terakhirDipicu < alert.cooldownMs) {
    return { picu: false, alasan: 'cooldown' }
  }

  return { picu: true, alasan: 'tercapai' }
}

Perhatikan parameter ketiga sekarang dengan nilai bawaan Date.now(). Itu membuat fungsi ini bisa diuji pada waktu mana pun tanpa memalsukan jam sistem — trik kecil yang membuat perbedaan besar saat menulis test.

Field alasan bukan sekadar untuk log. Ia yang memungkinkanmu menjawab pertanyaan pengguna "kenapa saya tidak dikabari?" dengan pasti.

src/worker/pemantau.ts

ts
import { mastra } from '../mastra/index.ts'
import { evaluasiAlert } from '../domain/alert.ts'
import { ambilHarga } from '../infra/harga.ts'
import { alertAktif, catatDipicu } from '../infra/repo-alert.ts'
import { kirimNotifikasi } from '../infra/notifikasi.ts'

const INTERVAL_MS = 30_000

export async function jalankanPemantau(): Promise<void> {
  const agent = mastra.getAgentById('notifikasi-agent')

  setInterval(async () => {
    const daftar = await alertAktif()

    // Ambil harga sekali per ticker, bukan sekali per alert.
    const ticker = [...new Set(daftar.map(a => a.ticker))]
    const harga = await ambilHarga(ticker)

    for (const alert of daftar) {
      const h = harga[alert.ticker]
      if (h === undefined) continue

      const hasil = evaluasiAlert(alert, h)
      if (!hasil.picu) continue        // ← jalur murah, tidak menyentuh model

      const pesan = await agent.generate(
        `Alert ${alert.ticker} tercapai. Target ${alert.arah} ${alert.target}, harga sekarang ${h}.`,
      )

      await kirimNotifikasi(alert.id, pesan.text)
      await catatDipicu(alert.id, Date.now())
    }
  }, INTERVAL_MS)
}

Baris const ticker = [...new Set(...)] mudah dilewatkan tapi penting: kalau 100 pengguna memasang alert pada BBCA, tanpa dedup kamu memanggil API harga 100 kali per siklus.

src/mastra/agents/notifikasi-agent.ts

ts
import { Agent } from '@mastra/core/agent'

export const notifikasiAgent = new Agent({
  id: 'notifikasi-agent',
  name: 'Notifikasi Alert',
  instructions: `Kamu menyusun pesan notifikasi harga saham.

Tulis SATU kalimat pendek yang memuat: ticker, harga sekarang, dan target
yang tercapai. Nada informatif, tanpa basa-basi, tanpa emoji berlebihan.

JANGAN memberi saran beli atau jual. JANGAN memprediksi arah harga.
Kamu hanya melaporkan fakta.`,
  model: 'openai/gpt-5-mini',
})

Dua larangan terakhir bukan gaya penulisan — itu batasan kepatuhan. Notifikasi otomatis yang menyarankan beli atau jual bisa dianggap saran investasi, dan itu wilayah yang diatur regulator.

src/domain/alert.test.ts

ts
import { describe, it, expect } from 'vitest'
import { evaluasiAlert, type Alert } from './alert.ts'

const dasar: Alert = {
  id: 'A-1',
  ticker: 'BBCA',
  arah: 'naik',
  target: 123,
  status: 'aktif',
  cooldownMs: 60 * 60 * 1000,
  terakhirDipicu: null,
}

describe('evaluasiAlert', () => {
  it('diam saat harga di bawah target', () => {
    expect(evaluasiAlert(dasar, 120).picu).toBe(false)
  })

  it('memicu tepat di target', () => {
    expect(evaluasiAlert(dasar, 123).picu).toBe(true)
  })

  it('diam saat masih cooldown', () => {
    const a = { ...dasar, terakhirDipicu: Date.now() - 5 * 60 * 1000 }
    expect(evaluasiAlert(a, 130).alasan).toBe('cooldown')
  })

  it('memicu lagi setelah cooldown lewat', () => {
    const a = { ...dasar, terakhirDipicu: Date.now() - 2 * 60 * 60 * 1000 }
    expect(evaluasiAlert(a, 130).picu).toBe(true)
  })

  it('diam saat alert dinonaktifkan', () => {
    expect(evaluasiAlert({ ...dasar, status: 'nonaktif' }, 130).picu).toBe(false)
  })
})

Lima test, nol panggilan model, nol biaya, jalan dalam milidetik. Ini yang dibeli oleh keputusan 1.

Hasil nyata

Logika evaluasi dijalankan pada enam skenario:

text
alert: BBCA naik >= 123, cooldown 1 jam

diam   harga=120  harga masih di bawah target    -> belum-tercapai
PICU   harga=123  harga menyentuh target         -> tercapai
PICU   harga=130  harga lewat target             -> tercapai
diam   harga=130  sudah dipicu 5 menit lalu      -> cooldown
PICU   harga=130  sudah dipicu 2 jam lalu        -> tercapai
diam   harga=130  alert dimatikan user           -> alert-nonaktif

Empat hal terbukti dari tabel ini:

  1. Batas tepat benar — harga 123 memicu, karena >= bukan >
  2. Cooldown bekerja — 5 menit setelah dipicu tetap diam meski harga masih di atas target
  3. Cooldown melepas — 2 jam kemudian memicu lagi
  4. Empat alasan berbeda bisa dibedakan, sehingga "kenapa saya tidak dikabari" selalu terjawab

Perhatikan bahwa dari enam skenario, hanya tiga yang memanggil model. Tiga sisanya berhenti di kode biasa dengan biaya nol.

Best practice yang dipakai di sini

1. Kondisi numerik dievaluasi kode, bukan model. Deterministik, gratis, dan bisa diuji.

2. Fungsi murni dengan parameter waktu. sekarang: number = Date.now() membuat pengujian cooldown mungkin tanpa memanipulasi jam sistem.

3. alasan untuk setiap keputusan diam. Bukan sekadar boolean — kamu harus bisa menjelaskan kenapa notifikasi tidak terkirim.

4. Model hanya dipanggil di jalur yang jarang. Jalur picu: false yang paling sering diambil sama sekali tidak menyentuh LLM.

5. Dedup pemanggilan API. Satu ticker diambil sekali per siklus, berapa pun jumlah alert yang memantaunya.

6. Batasan kepatuhan ditulis di instruksi. Larangan memberi saran investasi bukan preferensi gaya.

Kapan pola ini salah

SituasiKenapa gagalKe mana
Kondisinya kabur — "kabari kalau ada berita buruk"Tidak bisa dievaluasi >=Agent + structured output untuk klasifikasi
Butuh latensi di bawah 1 detikPolling 30 detik terlalu lambatWebhook penyedia
Ribuan pengguna, masing-masing puluhan alertSatu worker tidak cukupAntrian + beberapa worker
Worker mati dan tidak ada yang tahuAlert diam-diam berhentiObservability, 10.15

Baris terakhir adalah kegagalan paling berbahaya di kasus ini: sistem alert yang mati terlihat sama persis dengan sistem alert yang tidak menemukan apa-apa. Keduanya diam.

Coba sendiri

Bangun alert cuaca — kabari saat suhu di kota tertentu melewati ambang yang ditetapkan pengguna.

Tambahkan satu kerumitan: dukung kondisi di-luar-rentang (misalnya "kabari kalau suhu di bawah 20 atau di atas 33").

Selesai kalau

  • [ ] Logika evaluasi ada di domain/, tidak mengimpor Mastra sama sekali
  • [ ] Ada minimal 6 unit test yang jalan tanpa API key
  • [ ] Kondisi di-luar-rentang benar di kedua sisi, termasuk tepat di batas
  • [ ] Cooldown bekerja — buktikan dengan test, bukan dengan menunggu
  • [ ] Model tidak dipanggil sama sekali saat kondisi belum tercapai — buktikan dengan console.log di dalam agent
  • [ ] Setiap keputusan diam punya alasan yang bisa dibaca

Kalau macet

Notifikasi membanjir? Cooldown tidak tercatat. Pastikan catatDipicu dipanggil setelah notifikasi terkirim, dan nilainya benar-benar tersimpan — bukan hanya di memori proses yang hilang saat restart.

Test cooldown rapuh dan kadang gagal? Kamu memakai Date.now() langsung di dalam test. Suntikkan waktu lewat parameter ketiga.

Lanjut ke mana

Tujuh kasus pertama semuanya memakai Topologi A — satu agent, alur sederhana. Kasus berikutnya masuk ke wilayah yang berbeda: proses bisnis dengan langkah pasti, percabangan, dan antrian — 10.8 Antrian Booking.

Materi belajar mandiri. Bukan dokumentasi resmi Mastra.