Spaces:
Sleeping
Sleeping
| # ============================================================================ | |
| # 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 | |