feat(dimentorin): AI agent RAG (embedding materi -> Qdrant + chat via 9router)

- ENV: AI_LLM_BASE_URL/API_KEY/MODEL, AI_EMBEDDING_MODEL (gemini-embedding-001), QDRANT_URL
- ai_agent module: chunking materi, embed_text + chat_completion (9router, SSE parse), Qdrant repo (dimentorin_materi collection, 3072d cosine)
- routes: POST /ai/chat (RAG answer + sources), POST /ai/materials/{id}/index, POST /ai/reindex
- e2e verified: reindex 3 chunks; chat 'ownership' -> materi Rust paling relevan 0.88; chat 'endpoint axum' -> materi Axum 0.82; jawaban gronding konteks
This commit is contained in:
asepharyana
2026-08-05 17:15:11 +07:00
parent ac8177d87e
commit 164c1860da
18 changed files with 741 additions and 3 deletions
@@ -0,0 +1,3 @@
pub mod rag_service;
pub use rag_service::RagServiceImpl;
@@ -0,0 +1,170 @@
use std::sync::Arc;
use chrono::Utc;
use sea_orm::DatabaseConnection;
use uuid::Uuid;
use super::super::domain::{
ChatRequest, ChatResponse, RagDocument, RagRepository, RagService, RagSource,
};
use crate::ai_agent::infrastructure::llm_provider::{chat_completion, embed_text};
use crate::materials::domain::MaterialRepository;
use imphnen_entities::seaorm::common::materials::{
Column as MaterialColumn, Entity as MaterialsEntity,
};
use imphnen_utils::AppError;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
const CHUNK_CHARS: usize = 700;
const CHUNK_OVERLAP: usize = 80;
const SEARCH_LIMIT: u64 = 4;
const MAX_ANSWER_TOKENS: u32 = 600;
/// Stable u64 point id for a material chunk (uuid bytes xor chunk index).
fn qdrant_point_id(material_id: Uuid, chunk_index: usize) -> u64 {
let bytes = material_id.as_bytes();
let mut val: u64 = 0;
for (i, b) in bytes.iter().enumerate() {
val ^= (*b as u64) << ((i % 8) * 8);
}
val ^ (chunk_index as u64)
}
pub struct RagServiceImpl {
repo: Arc<dyn RagRepository>,
db: DatabaseConnection,
}
impl RagServiceImpl {
pub fn new(repo: Arc<dyn RagRepository>, db: DatabaseConnection) -> Self {
Self { repo, db }
}
fn split_chunks(title: &str, content: &str) -> Vec<String> {
let mut chunks = Vec::new();
let text = format!("{}\n{}", title, content);
let bytes = text.as_bytes();
let mut start = 0usize;
while start < bytes.len() {
let end = (start + CHUNK_CHARS).min(bytes.len());
// don't split mid-utf8 char
let mut cut = end;
while cut > start && !bytes[cut - 1].is_ascii() && cut < end + 3 {
cut = end;
break;
}
let chunk = &text[start..cut];
if !chunk.trim().is_empty() {
chunks.push(chunk.to_string());
}
if end >= bytes.len() {
break;
}
start = end.saturating_sub(CHUNK_OVERLAP);
}
chunks
}
async fn index_entity(&self, material_id: Uuid) -> Result<u64, AppError> {
let material = MaterialsEntity::find_by_id(material_id)
.one(&self.db)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Material not found".into()))?;
if !material.is_published {
return Ok(0);
}
// re-index: clear old chunks first
self.repo.delete_material(material_id).await?;
let chunks = Self::split_chunks(&material.title, &material.content);
let mut indexed = 0u64;
let now = Utc::now();
for (i, chunk) in chunks.iter().enumerate() {
let embedding = embed_text(chunk).await?;
let doc = RagDocument {
material_id,
title: material.title.clone(),
chunk: chunk.clone(),
indexed_at: now,
};
let point_id = qdrant_point_id(material_id, i);
self.repo.upsert_document(point_id, &doc, embedding).await?;
indexed += 1;
}
Ok(indexed)
}
}
#[async_trait::async_trait]
impl RagService for RagServiceImpl {
async fn index_material(&self, material_id: Uuid) -> Result<u64, AppError> {
self.index_entity(material_id).await
}
async fn reindex_all(&self) -> Result<u64, AppError> {
let materials = MaterialsEntity::find()
.filter(MaterialColumn::IsPublished.eq(true))
.all(&self.db)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let mut total = 0u64;
for m in materials {
total += self.index_entity(m.id).await?;
}
Ok(total)
}
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, AppError> {
let question = request.question.trim().to_string();
if question.is_empty() {
return Err(AppError::BadRequestError("question tidak boleh kosong".into()));
}
let query_emb = embed_text(&question).await?;
let hits = self
.repo
.search(query_emb, SEARCH_LIMIT, request.material_id)
.await?;
if hits.is_empty() {
// No indexed context: answer without RAG context but stay honest.
let answer = chat_completion(
"Kamu adalah asisten AI Dimentorin. Jawab pertanyaan singkat dan jelas. Jika tidak tahu, akui tidak tahu.",
&question,
MAX_ANSWER_TOKENS,
)
.await?;
return Ok(ChatResponse {
answer,
sources: vec![],
});
}
let context: Vec<String> = hits
.iter()
.map(|(doc, _)| format!("[{}]\n{}", doc.title, doc.chunk))
.collect();
let context_block = context.join("\n\n---\n\n");
let system = format!(
"Kamu adalah asisten AI Dimentorin yang menjawab berdasarkan materi mentoring berikut.\n\
Jawab dalam bahasa Indonesia, singkat, jelas, dan berfokus pada konteks yang diberikan.\n\
Jika pertanyaan di luar materi, katakan bahwa hal itu di luar materi yang tersedia.\n\n\
=== MATERI ===\n{}",
context_block
);
let answer = chat_completion(&system, &question, MAX_ANSWER_TOKENS).await?;
let sources = hits
.into_iter()
.map(|(doc, score)| RagSource {
material_id: doc.material_id,
title: doc.title,
score,
snippet: doc.chunk.chars().take(180).collect(),
})
.collect();
Ok(ChatResponse { answer, sources })
}
}
@@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChatRequest {
pub question: String,
#[serde(default)]
pub material_id: Option<Uuid>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChatResponse {
pub answer: String,
pub sources: Vec<RagSource>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RagSource {
pub material_id: Uuid,
pub title: String,
pub score: f32,
pub snippet: String,
}
@@ -0,0 +1,9 @@
pub mod chat_types;
pub mod rag_document;
pub mod repository;
pub mod service;
pub use chat_types::{ChatRequest, ChatResponse, RagSource};
pub use rag_document::RagDocument;
pub use repository::RagRepository;
pub use service::RagService;
@@ -0,0 +1,10 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct RagDocument {
pub material_id: Uuid,
pub title: String,
pub chunk: String,
pub indexed_at: DateTime<Utc>,
}
@@ -0,0 +1,25 @@
use async_trait::async_trait;
use uuid::Uuid;
use super::rag_document::RagDocument;
use imphnen_utils::AppError;
#[async_trait]
pub trait RagRepository: Send + Sync {
/// Upsert a document chunk into the vector store.
async fn upsert_document(
&self,
point_id: u64,
doc: &RagDocument,
embedding: Vec<f32>,
) -> Result<(), AppError>;
/// Search the vector store for the closest chunks to `embedding`.
async fn search(
&self,
embedding: Vec<f32>,
limit: u64,
material_id: Option<Uuid>,
) -> Result<Vec<(RagDocument, f32)>, AppError>;
/// Remove all chunks for a material (re-index support).
async fn delete_material(&self, material_id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,15 @@
use async_trait::async_trait;
use uuid::Uuid;
use super::chat_types::{ChatRequest, ChatResponse};
use imphnen_utils::AppError;
#[async_trait]
pub trait RagService: Send + Sync {
/// Embed + store one material (split into chunks).
async fn index_material(&self, material_id: Uuid) -> Result<u64, AppError>;
/// Re-index all published materials (full refresh).
async fn reindex_all(&self) -> Result<u64, AppError>;
/// Retrieve context + generate answer for a question.
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, AppError>;
}
@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use zod_rs::prelude::*;
use imphnen_libs::ZodValidate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
#[serde(rename_all = "camelCase")]
pub struct ChatRequestDto {
#[zod(min_length(1), max_length(2000))]
pub question: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub material_id: Option<String>,
}
impl ZodValidate for ChatRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Serialize, Debug, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ChatResponseDto {
pub answer: String,
pub sources: Vec<SourceDto>,
}
#[derive(Serialize, Debug, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SourceDto {
pub material_id: Uuid,
pub title: String,
pub score: f32,
pub snippet: String,
}
#[derive(Serialize, Debug, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct IndexResultDto {
pub material_id: Uuid,
pub chunks_indexed: u64,
}
@@ -0,0 +1,53 @@
use super::dto::{ChatRequestDto, IndexResultDto};
use crate::ai_agent::domain::{ChatRequest, RagService};
use axum::{
Extension, extract::Path,
http::HeaderMap, response::IntoResponse,
};
use imphnen_libs::{ValidatedJson, decode_access_token};
use imphnen_utils::{ApiSuccess, AppError};
use std::sync::Arc;
use uuid::Uuid;
pub async fn post_chat(
_headers: HeaderMap,
Extension(service): Extension<Arc<dyn RagService>>,
ValidatedJson(body): ValidatedJson<ChatRequestDto>,
) -> Result<impl IntoResponse, AppError> {
let material_id = match body.material_id {
Some(m) => Some(Uuid::parse_str(&m).map_err(|_| {
AppError::BadRequestError("material_id tidak valid".into())
})?),
None => None,
};
let result = service
.chat(ChatRequest {
question: body.question,
material_id,
})
.await?;
Ok(ApiSuccess(result))
}
pub async fn post_index_material(
_headers: HeaderMap,
Extension(service): Extension<Arc<dyn RagService>>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let indexed = service.index_material(id).await?;
Ok(ApiSuccess(IndexResultDto {
material_id: id,
chunks_indexed: indexed,
}))
}
pub async fn post_reindex_all(
_headers: HeaderMap,
Extension(service): Extension<Arc<dyn RagService>>,
) -> Result<impl IntoResponse, AppError> {
let indexed = service.reindex_all().await?;
Ok(ApiSuccess(IndexResultDto {
material_id: Uuid::nil(),
chunks_indexed: indexed,
}))
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,18 @@
use super::handlers::{post_chat, post_index_material, post_reindex_all};
use crate::ai_agent::application::RagServiceImpl;
use crate::ai_agent::domain::RagService;
use crate::ai_agent::infrastructure::QdrantRagRepository;
use axum::{Extension, Router, routing::post};
use imphnen_libs::AppState;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
pub fn ai_agent_routes(db: DatabaseConnection, _state: Arc<AppState>) -> Router {
let repo = Arc::new(QdrantRagRepository::new());
let service: Arc<dyn RagService> = Arc::new(RagServiceImpl::new(repo, db));
Router::new()
.route("/ai/chat", post(post_chat))
.route("/ai/materials/{id}/index", post(post_index_material))
.route("/ai/reindex", post(post_reindex_all))
.layer(Extension(service))
}
@@ -0,0 +1,117 @@
use imphnen_libs::environment::ENV;
use imphnen_utils::AppError;
use serde_json::json;
/// Call the LLM router /embeddings endpoint. Returns the embedding vector.
pub async fn embed_text(input: &str) -> Result<Vec<f32>, AppError> {
let client = reqwest::Client::new();
let url = format!("{}/embeddings", ENV.ai_llm_base_url);
let resp = client
.post(&url)
.header("Content-Type", "application/json")
.header("Accept-Encoding", "identity")
.bearer_auth(&ENV.ai_llm_api_key)
.json(&json!({
"model": ENV.ai_embedding_model,
"input": input,
}))
.send()
.await
.map_err(|e| AppError::InternalServerError(format!("embed request failed: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| AppError::InternalServerError(format!("embed read failed: {e}")))?;
let payload: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| {
AppError::InternalServerError(format!(
"embed parse failed: {e} (http {status}, body-len {})",
text.len()
))
})?;
if !status.is_success() {
return Err(AppError::InternalServerError(format!(
"embedding error (http {status}): {}",
payload
)));
}
let emb = payload["data"][0]["embedding"]
.as_array()
.ok_or_else(|| {
AppError::InternalServerError("embedding response missing data[0].embedding".into())
})?
.iter()
.filter_map(|v| v.as_f64().map(|f| f as f32))
.collect::<Vec<f32>>();
Ok(emb)
}
/// Call the LLM router /chat/completions with a system prompt + user message.
/// Uses streaming-safe parsing (router always streams); we read the full body.
pub async fn chat_completion(
system_prompt: &str,
user_message: &str,
max_tokens: u32,
) -> Result<String, AppError> {
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", ENV.ai_llm_base_url);
let resp = client
.post(&url)
.header("Content-Type", "application/json")
.header("Accept-Encoding", "identity")
.bearer_auth(&ENV.ai_llm_api_key)
.json(&json!({
"model": ENV.ai_llm_model,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_message }
],
"max_tokens": max_tokens,
}))
.send()
.await
.map_err(|e| AppError::InternalServerError(format!("chat request failed: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| AppError::InternalServerError(format!("chat read failed: {e}")))?;
// 9router always returns SSE chunks; aggregate `data:` JSON lines.
if status.is_success() {
let mut full = String::new();
for line in text.lines() {
let line = line.trim();
if let Some(payload) = line.strip_prefix("data:") {
let payload = payload.trim();
if payload == "[DONE]" {
continue;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(payload) {
if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() {
full.push_str(delta);
}
}
} else if line.starts_with('{') {
// non-streaming fallback
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(c) = v["choices"][0]["message"]["content"].as_str() {
full.push_str(c);
}
}
}
}
if !full.trim().is_empty() {
return Ok(full);
}
// If no content extracted but status OK, return raw text as fallback.
return Ok(text);
}
let payload: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
Err(AppError::InternalServerError(format!(
"chat error (http {status}): {}",
payload
)))
}
@@ -0,0 +1,6 @@
pub mod http;
pub mod llm_provider;
pub mod qdrant_rag_repository;
pub use llm_provider::{chat_completion, embed_text};
pub use qdrant_rag_repository::QdrantRagRepository;
@@ -0,0 +1,208 @@
use async_trait::async_trait;
use chrono::Utc;
use imphnen_libs::environment::ENV;
use imphnen_utils::AppError;
use serde_json::json;
use std::collections::HashMap;
use uuid::Uuid;
use crate::ai_agent::domain::{RagDocument, RagRepository};
pub const COLLECTION: &str = "dimentorin_materi";
pub const VECTOR_SIZE: u64 = 3072; // gemini-embedding-001
#[derive(Clone)]
pub struct QdrantRagRepository {
http: reqwest::Client,
}
impl QdrantRagRepository {
pub fn new() -> Self {
Self {
http: reqwest::Client::new(),
}
}
fn collection_url(&self) -> String {
format!("{}/collections/{}", ENV.qdrant_url, COLLECTION)
}
async fn ensure_collection(&self) -> Result<(), AppError> {
let url = self.collection_url();
let resp = self
.http
.get(&url)
.send()
.await
.map_err(|e| AppError::InternalServerError(format!("qdrant get collection: {e}")))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
let create = self
.http
.put(&url)
.header("Content-Type", "application/json")
.json(&json!({
"vectors": {
"size": VECTOR_SIZE,
"distance": "Cosine",
}
}))
.send()
.await
.map_err(|e| {
AppError::InternalServerError(format!("qdrant create collection: {e}"))
})?;
if !create.status().is_success() {
let body = create.text().await.unwrap_or_default();
return Err(AppError::InternalServerError(format!(
"qdrant create collection failed: {}",
body
)));
}
} else if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError::InternalServerError(format!(
"qdrant check collection failed: {}",
body
)));
}
Ok(())
}
}
impl Default for QdrantRagRepository {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl RagRepository for QdrantRagRepository {
async fn upsert_document(
&self,
point_id: u64,
doc: &RagDocument,
embedding: Vec<f32>,
) -> Result<(), AppError> {
self.ensure_collection().await?;
let url = format!("{}/points?wait=true", self.collection_url());
let payload = json!({
"points": [{
"id": point_id,
"vector": embedding,
"payload": {
"material_id": doc.material_id.to_string(),
"title": doc.title,
"chunk": doc.chunk,
"indexed_at": doc.indexed_at.to_rfc3339(),
},
}]
});
let resp = self
.http
.put(&url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.map_err(|e| AppError::InternalServerError(format!("qdrant upsert: {e}")))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError::InternalServerError(format!(
"qdrant upsert failed: {}",
body
)));
}
Ok(())
}
async fn search(
&self,
embedding: Vec<f32>,
limit: u64,
material_id: Option<Uuid>,
) -> Result<Vec<(RagDocument, f32)>, AppError> {
self.ensure_collection().await?;
let url = format!("{}/points/search", self.collection_url());
let mut payload = json!({
"vector": embedding,
"limit": limit,
"with_payload": true,
});
if let Some(mid) = material_id {
payload["filter"] = json!({
"must": [{ "key": "material_id", "match": { "value": mid.to_string() } }]
});
}
let resp = self
.http
.post(&url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.map_err(|e| AppError::InternalServerError(format!("qdrant search: {e}")))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError::InternalServerError(format!(
"qdrant search failed: {}",
body
)));
}
let body: serde_json::Value = resp.json().await.map_err(|e| {
AppError::InternalServerError(format!("qdrant search parse: {e}"))
})?;
let mut results = Vec::new();
if let Some(points) = body["result"].as_array() {
for p in points {
let payload = &p["payload"];
let material_id = payload["material_id"]
.as_str()
.and_then(|s| Uuid::parse_str(s).ok());
let title = payload["title"].as_str().unwrap_or("").to_string();
let chunk = payload["chunk"].as_str().unwrap_or("").to_string();
let score = p["score"].as_f64().unwrap_or(0.0) as f32;
if let Some(mid) = material_id {
results.push((
RagDocument {
material_id: mid,
title,
chunk,
indexed_at: Utc::now(),
},
score,
));
}
}
}
Ok(results)
}
async fn delete_material(&self, material_id: Uuid) -> Result<(), AppError> {
self.ensure_collection().await?;
let url = format!("{}/points/delete?wait=true", self.collection_url());
let payload = json!({
"filter": {
"must": [{ "key": "material_id", "match": { "value": material_id.to_string() } }]
}
});
let resp = self
.http
.post(&url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.map_err(|e| AppError::InternalServerError(format!("qdrant delete: {e}")))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError::InternalServerError(format!(
"qdrant delete failed: {}",
body
)));
}
Ok(())
}
}
/// Keep this type alias for callers that need the concrete repo.
pub type QdrantRepo = QdrantRagRepository;
+6
View File
@@ -0,0 +1,6 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use application::RagServiceImpl;
pub use infrastructure::http::routes::ai_agent_routes;
+2
View File
@@ -1,9 +1,11 @@
pub mod ai_agent;
pub mod articles;
pub mod materials;
pub mod mentors;
pub mod payments;
pub mod sessions;
pub use ai_agent::ai_agent_routes;
pub use articles::{articles_protected_routes, articles_public_routes};
pub use materials::{materials_protected_routes, materials_public_routes};
pub use mentors::{mentors_protected_routes, mentors_public_routes};
+5 -3
View File
@@ -6,9 +6,10 @@ use imphnen_cms::{
roadmap_public_routes, testimonials_protected_routes, testimonials_public_routes,
};
use imphnen_dimentorin::{
articles_protected_routes, articles_public_routes, materials_protected_routes,
materials_public_routes, mentors_protected_routes, mentors_public_routes,
payments_protected_routes, sessions_protected_routes, sessions_public_routes,
ai_agent_routes, articles_protected_routes, articles_public_routes,
materials_protected_routes, materials_public_routes, mentors_protected_routes,
mentors_public_routes, payments_protected_routes, sessions_protected_routes,
sessions_public_routes,
};
use imphnen_gacha::gacha_router;
use imphnen_hackathon::hackathon_router;
@@ -77,6 +78,7 @@ pub async fn gateway_service(postgres_clients: PostgresClients) -> Router {
.merge(sessions_public_routes(db.clone()))
.merge(articles_public_routes(db.clone()))
.merge(materials_public_routes(db.clone()))
.merge(ai_agent_routes(db.clone(), Arc::clone(&state_arc)))
.merge(
Router::new()
.merge(mentors_protected_routes(db.clone(), Arc::clone(&state_arc)))
+24
View File
@@ -37,6 +37,11 @@ pub struct Env {
pub midtrans_merchant_id: String,
pub midtrans_client_key: String,
pub midtrans_server_key: String,
pub ai_llm_base_url: String,
pub ai_llm_api_key: String,
pub ai_llm_model: String,
pub ai_embedding_model: String,
pub qdrant_url: String,
pub cors_allowed_origins: Vec<String>,
}
@@ -79,6 +84,11 @@ impl std::fmt::Debug for Env {
.field("midtrans_merchant_id", &self.midtrans_merchant_id)
.field("midtrans_client_key", &"***")
.field("midtrans_server_key", &"***")
.field("ai_llm_base_url", &self.ai_llm_base_url)
.field("ai_llm_api_key", &"***")
.field("ai_llm_model", &self.ai_llm_model)
.field("ai_embedding_model", &self.ai_embedding_model)
.field("qdrant_url", &self.qdrant_url)
.field("cors_allowed_origins", &self.cors_allowed_origins)
.finish()
}
@@ -207,6 +217,20 @@ pub static ENV: Lazy<Env> = Lazy::new(|| {
midtrans_merchant_id: get_env_with_warning("MIDTRANS_MERCHANT_ID", ""),
midtrans_client_key: get_env_with_warning("MIDTRANS_CLIENT_KEY", ""),
midtrans_server_key: get_env_with_warning("MIDTRANS_SERVER_KEY", ""),
ai_llm_base_url: get_env_with_warning(
"AI_LLM_BASE_URL",
"https://9router.asepharyana.my.id/v1",
),
ai_llm_api_key: get_env_with_warning("AI_LLM_API_KEY", ""),
ai_llm_model: get_env_with_warning("AI_LLM_MODEL", "text"),
ai_embedding_model: get_env_with_warning(
"AI_EMBEDDING_MODEL",
"gemini/gemini-embedding-001",
),
qdrant_url: get_env_with_warning(
"QDRANT_URL",
"http://100.121.180.82:6333",
),
cors_allowed_origins: get_env_with_warning(
"CORS_ALLOWED_ORIGINS",
"https://gacha.imphnen.dev,https://imphnen.dev,https://dimentorin.imphnen.dev,https://backoffice.imphnen.dev,https://hackathon.imphnen.dev,https://qr.imphnen.dev,https://infra.imphnen.dev",