mpg-highway-api / 02_api_plumber.R
aephidayatuloh
fix: load tidymodels and ranger libraries for vetiver execution
7a3dfe0
# ============================================================================
# Script 2: Deployment API dengan Plumber dan Vetiver
# ============================================================================
library(plumber)
library(vetiver)
library(pins)
library(dplyr)
library(purrr)
library(jsonlite)
library(RPostgres)
library(tidymodels)
library(ranger)
# Cek apakah file .env ada di folder (biasanya saat di laptop lokal)
# Jika tidak ada (seperti di Hugging Face), lewati saja agar tidak error
if (file.exists(".env")) {
library(dotenv)
load_dot_env(".env")
}
source("00_db_helper.R")
# Load model and metadata
model_board <- NULL
v <- NULL
train_stats <- NULL
model_metadata <- NULL
tryCatch({
model_board <- board_folder("models", versioned = TRUE)
v <- vetiver_pin_read(model_board, "mpg_highway_model")
# print(v)
# 🟒 PENYESUAIAN HUGGING FACE:
# Karena 03_run_server.R sudah mengunduh file .rds langsung ke folder "models",
# kita tidak perlu menggunakan board_folder() yang mencari struktur folder timestamp.
# Kita cukup membaca file RDS-nya secara langsung!
# Membaca model
# v <- readRDS("models/mpg_highway_model.rds")
# Load training statistics
if (file.exists("models/train_statistics.rds")) {
train_stats <- readRDS("models/train_statistics.rds")
}
# Load model metadata
if (file.exists("models/model_metadata.rds")) {
model_metadata <- readRDS("models/model_metadata.rds")
}
message("βœ“ Model loaded successfully")
}, error = function(e) {
message("⚠️ No model found. Please run 01_train_and_version.R first")
message(" API will start in limited mode")
})
# ============================================================================
# CREATE API
# ============================================================================
#* @apiTitle MPG Highway Prediction API
#* @apiDescription API untuk prediksi highway mileage dengan monitoring
api <-
pr() %>%
# --- VETIVER ENDPOINTS (OTOMATIS) ---
# Ini akan otomatis membuat:
# - POST /predict : untuk prediksi standar (tanpa row_id)
# - GET /ping : health check
vetiver_api(v) %>%
# 2. PAKSA ubah Judul dan Deskripsi menggunakan fungsi R (Bukan komentar)
pr_set_api_spec(function(spec) {
spec$info$title <- "MPG Highway Prediction API"
spec$info$version <- "1.0.0"
spec$info$description <- "API untuk prediksi highway mileage dengan monitoring"
# Tambahkan deskripsi untuk endpoint /ping (Health Check)
if (!is.null(spec$paths$`/ping`$get)) {
spec$paths$`/ping`$get$summary <- "REST API Health Check"
spec$paths$`/ping`$get$description <- "Memastikan bahwa server API berjalan dengan baik dan model siap digunakan."
}
# Tambahkan deskripsi untuk endpoint /predict (Bawaan Vetiver)
if (!is.null(spec$paths$`/predict`$post)) {
spec$paths$`/predict`$post$summary <- "Prediksi Standar Vetiver"
spec$paths$`/predict`$post$description <- "Melakukan prediksi konsumsi bahan bakar (highway) menggunakan skema standar Vetiver tanpa menyertakan row_id."
# PAKSA masukkan skema parameter JSON Body ke Swagger
spec$paths$`/predict`$post$requestBody <- list(
required = TRUE,
content = list(
`application/json` = list(
schema = list(
type = "object",
properties = list(
displ = list(type = "number", example = 2.4, description = "Engine displacement in liters"),
year = list(type = "integer", example = 2008, description = "Year of manufacture"),
cyl = list(type = "integer", example = 4, description = "Number of cylinders"),
class = list(type = "string", example = "compact", description = "Vehicle class")
),
required = c("displ", "year", "cyl", "class") # Opsional: Tandai kolom yang wajib diisi
)
)
)
)
}
# ---------------------------------------------------------
# Deskripsi untuk Endpoint Kustom (DI SINI KUNCINYA!)
# ---------------------------------------------------------
if (!is.null(spec$paths$`/predict_custom`$post)) {
spec$paths$`/predict_custom`$post$summary <- "Prediksi Kustom & Logging ke Database"
spec$paths$`/predict_custom`$post$description <- "Endpoint khusus untuk melakukan prediksi yang secara otomatis memisahkan 'row_id' sebelum diprediksi dan menyimpannya bersama payload ke log SQLite. Digunakan untuk siklus monitoring penuh."
spec$paths$`/predict_custom`$post$requestBody <- list(
required = TRUE,
content = list(
`application/json` = list(
schema = list(
type = "object",
properties = list(
row_id = list(type = "string", example = "1", description = "ID Baris kustom untuk pemetaan data aktual"),
displ = list(type = "number", example = 2.4),
year = list(type = "integer", example = 2008),
cyl = list(type = "integer", example = 4),
class = list(type = "string", example = "compact")
)
)
)
)
)
}
if (!is.null(spec$paths$`/metadata`$get)) {
spec$paths$`/metadata`$get$summary <- "Informasi Detail Model & Metrik Evaluasi"
spec$paths$`/metadata`$get$description <- "Menampilkan nama model, deskripsi, tanggal training, jumlah baris data latih, dan metrik hasil evaluasi (RMSE, Rsq, MAE)."
}
if (!is.null(spec$paths$`/prototype`$get)) {
spec$paths$`/prototype`$get$summary <- "Akses Dashboard Prototype"
spec$paths$`/prototype`$get$description <- "Mengembalikan tampilan antarmuka (UI) sederhana untuk mencoba prediksi secara interaktif langsung dari peramban."
}
spec
}) %>%
# --- CUSTOM ENDPOINTS ---
#* Prediksi Kustom dengan Pencatatan row_id ke SQLite
#* @post /predict_custom
#* @summary Prediksi Kustom & Logging ke Database
#* @description Endpoint khusus untuk melakukan prediksi yang secara otomatis memisahkan 'row_id' sebelum diprediksi dan menyimpannya bersama payload ke log SQLite. Digunakan untuk siklus monitoring penuh.
#* @serializer unboxedJSON
#* @parser json list(simplifyDataFrame = FALSE)
pr_post("/predict_custom", function(req) {
# 1. Parsing body JSON otomatis menjadi Data Frame utuh oleh Plumber
if(!is.data.frame(req$body)){
input_data <- as.data.frame(req$body)
} else {
input_data <- req$body
}
num_rows <- nrow(input_data)
# Generate Request ID Unik
req_id <- paste0("REQ-", format(Sys.time(), "%y%m%d%H%M%S"), "-", sample(1000:9999, 1))
# Ambil prototype dari objek vetiver
model_prototype <- v$prototype
# Pisahkan row_id agar tidak ikut divalidasi atau diprediksi
data_for_pred <- input_data[, names(input_data) %in% names(model_prototype), drop = FALSE]
# 2. Bungkus seluruh proses dalam satu tryCatch (All-or-Nothing)
response <- tryCatch({
# πŸ•΅οΈβ€β™‚οΈ VALIDASI OTOMATIS RESMI:
# Kita ambil ptype (prototype) resmi dari objek vetiver
prototype_data <- v$prototype
# Gunakan vctrs untuk memaksa validasi tipe data secara massal
# Jika ada kolom yang tipenya salah (misal character ke numeric), ini akan langsung melempar eror!
valid_data <- vctrs::vec_cast(data_for_pred, prototype_data)
# EKSEKUSI PREDIKSI MASSAL (Menggunakan data yang sudah tervalidasi)
pred_result <- predict(v, valid_data)
predictions_vector <- as.numeric(pred_result$.pred)
# 3. Siapkan Data untuk Bulk Insert ke SQLite
# 3. Siapkan Data untuk Bulk Insert ke SQLite
# Siapkan Data untuk Bulk Insert ke SQLite
batch_db_data <- data.frame(
request_id = paste0(req_id, "-", 1:num_rows),
timestamp = rep(as.character(Sys.time()), num_rows),
variant = rep("standard", num_rows),
version = rep(v$metadata$version[1], num_rows),
# πŸ”‘ Tambahkan ini agar masuk ke database!
row_id = input_data$row_id,
input_features = sapply(1:num_rows, function(i) {
jsonlite::toJSON(as.list(input_data[i, , drop = FALSE]), auto_unbox = TRUE)
}),
predicted_value = predictions_vector,
status = "SUCCESS",
error_message = NA_character_
)
# Eksekusi Bulk Insert
con <- connect_supabase()
# 1. Pastikan R mengarah ke schema mlops
DBI::dbExecute(con, "SET search_path TO mlops")
DBI::dbBegin(con)
tryCatch({
# 1. Buat Query INSERT Standar secara dinamis
# Kita susun teks SQL: INSERT INTO predictions (col1, col2) VALUES ($1, $2)
placeholders <- paste0("$", 1:ncol(batch_db_data), collapse = ", ")
columns <- paste(names(batch_db_data), collapse = ", ")
sql_query <- paste0("INSERT INTO mlops.predictions (", columns, ") VALUES (", placeholders, ")")
# 2. Eksekusi query secara aman menggunakan dbExecute
# Ini mengirimkan data murni tanpa memicu fungsi COPY bawaan RPostgres
for (i in 1:nrow(batch_db_data)) {
DBI::dbExecute(con, sql_query, unname(as.list(batch_db_data[i, ])))
}
DBI::dbCommit(con)
cat("πŸŽ‰ Sukses murni! Log berhasil disimpan ke Supabase.\n")
}, error = function(e) {
DBI::dbRollback(con)
cat("❌ Gagal menyimpan log ke Supabase:", e$message, "\n")
})
DBI::dbDisconnect(con)
# Bentuk respon sukses untuk Client
results_list <- lapply(1:num_rows, function(i) {
list(
row_id = input_data$row_id[i],
status = "SUCCESS",
prediction = round(predictions_vector[i], 4)
)
})
list(
request_id = req_id,
status = "COMPLETED",
results = results_list
)
}, error = function(e) {
# Ambil pesan error asli
raw_msg <- e$message
# Deteksi jika error disebabkan oleh ketidakcocokan tipe data (vctrs)
clean_msg <- if (grepl("Can't convert", raw_msg)) {
# Ekstrak nama kolom dari pesan error vctrs menggunakan regex
col_match <- regmatches(raw_msg, regexec("`data_for_pred\\$(.*?)`", raw_msg))[[1]][2]
paste0("❌ Gagal Validasi: Tipe data pada kolom [", col_match, "] tidak sesuai dengan standar model.")
} else {
paste0("❌ Kesalahan Sistem: ", raw_msg)
}
# Kembalikan respon gagal total yang sudah rapi
list(
request_id = req_id,
status = "FAILED",
message = clean_msg
)
})
return(response)
}) %>%
#* Model metadata
#* @get /metadata
#* @summary Informasi Detail Model & Metrik Evaluasi
#* @description Menampilkan nama model, deskripsi, tanggal training, jumlah baris data latih, dan metrik hasil evaluasi (RMSE, Rsq, MAE). Membantu Anda mengidentifikasi performa baseline model yang sedang aktif.
#* @serializer unboxedJSON
pr_get("/metadata", function() {
if (is.null(v)) {
return(list(
error = "No model loaded",
message = "Please train model first"
))
}
result <- list(
model_name = v$model_name,
description = v$description
)
if (!is.null(model_metadata)) {
result$train_date <- as.character(model_metadata$train_date)
result$train_rows <- model_metadata$train_rows
result$test_rmse <- model_metadata$test_rmse
result$test_rsq <- model_metadata$test_rsq
result$test_mae <- model_metadata$test_mae
}
return(result)
}) %>%
#* @assets ./index.html /
#* @get /
pr_get("/", function(req, res) {
res$status <- 200
res$setHeader("Content-Type", "text/html")
include_html("index.html", res)
})
api