Tampilan
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 mengevaluasi | Kode biasa mengevaluasi | |
|---|---|---|
| Biaya per pengecekan | 1 panggilan model | Nol |
| Frekuensi wajar | Beberapa kali sehari | Tiap 10 detik |
| Konsistensi | Bisa berbeda antar pemanggilan | Selalu sama |
| Bisa diuji | Butuh scorer | Unit test biasa |
| Cocok untuk | Penilaian kabur | Perbandingan 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 picuKeputusan 2 — Bagaimana mencegah banjir notifikasi?
| Pendekatan | Cara kerja | Kelemahan |
|---|---|---|
| Alert sekali pakai | Nonaktif setelah dipicu | Pengguna harus mengaktifkan lagi |
| Cooldown | Diam N menit setelah dipicu | Bisa melewatkan pergerakan penting |
| Histeresis | Perlu turun dulu ke batas bawah | Lebih 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?
| Polling | Webhook penyedia | |
|---|---|---|
| Ketergantungan | Tidak ada | Penyedia harus mendukung |
| Latensi | Sebesar interval | Hampir seketika |
| Beban | Panggilan API terus-menerus | Hanya saat ada perubahan |
| Kerumitan | Rendah | Perlu 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.jsonPerhatikan 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-nonaktifEmpat hal terbukti dari tabel ini:
- Batas tepat benar — harga 123 memicu, karena
>=bukan> - Cooldown bekerja — 5 menit setelah dipicu tetap diam meski harga masih di atas target
- Cooldown melepas — 2 jam kemudian memicu lagi
- Empat
alasanberbeda 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
| Situasi | Kenapa gagal | Ke mana |
|---|---|---|
| Kondisinya kabur — "kabari kalau ada berita buruk" | Tidak bisa dievaluasi >= | Agent + structured output untuk klasifikasi |
| Butuh latensi di bawah 1 detik | Polling 30 detik terlalu lambat | Webhook penyedia |
| Ribuan pengguna, masing-masing puluhan alert | Satu worker tidak cukup | Antrian + beberapa worker |
| Worker mati dan tidak ada yang tahu | Alert diam-diam berhenti | Observability, 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-rentangbenar 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.logdi dalam agent - [ ] Setiap keputusan diam punya
alasanyang 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.