From 164c1860da3c5c92fe62c520c34bdaef1f51bb7a Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 5 Aug 2026 17:15:11 +0700 Subject: [PATCH] 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 --- .../src/ai_agent/application/mod.rs | 3 + .../src/ai_agent/application/rag_service.rs | 170 ++++++++++++++ .../src/ai_agent/domain/chat_types.rs | 23 ++ imphnen-dimentorin/src/ai_agent/domain/mod.rs | 9 + .../src/ai_agent/domain/rag_document.rs | 10 + .../src/ai_agent/domain/repository.rs | 25 +++ .../src/ai_agent/domain/service.rs | 15 ++ .../src/ai_agent/infrastructure/http/dto.rs | 44 ++++ .../ai_agent/infrastructure/http/handlers.rs | 53 +++++ .../src/ai_agent/infrastructure/http/mod.rs | 3 + .../ai_agent/infrastructure/http/routes.rs | 18 ++ .../ai_agent/infrastructure/llm_provider.rs | 117 ++++++++++ .../src/ai_agent/infrastructure/mod.rs | 6 + .../infrastructure/qdrant_rag_repository.rs | 208 ++++++++++++++++++ imphnen-dimentorin/src/ai_agent/mod.rs | 6 + imphnen-dimentorin/src/lib.rs | 2 + imphnen-gateway/src/lib.rs | 8 +- imphnen-libs/src/environment/mod.rs | 24 ++ 18 files changed, 741 insertions(+), 3 deletions(-) create mode 100644 imphnen-dimentorin/src/ai_agent/application/mod.rs create mode 100644 imphnen-dimentorin/src/ai_agent/application/rag_service.rs create mode 100644 imphnen-dimentorin/src/ai_agent/domain/chat_types.rs create mode 100644 imphnen-dimentorin/src/ai_agent/domain/mod.rs create mode 100644 imphnen-dimentorin/src/ai_agent/domain/rag_document.rs create mode 100644 imphnen-dimentorin/src/ai_agent/domain/repository.rs create mode 100644 imphnen-dimentorin/src/ai_agent/domain/service.rs create mode 100644 imphnen-dimentorin/src/ai_agent/infrastructure/http/dto.rs create mode 100644 imphnen-dimentorin/src/ai_agent/infrastructure/http/handlers.rs create mode 100644 imphnen-dimentorin/src/ai_agent/infrastructure/http/mod.rs create mode 100644 imphnen-dimentorin/src/ai_agent/infrastructure/http/routes.rs create mode 100644 imphnen-dimentorin/src/ai_agent/infrastructure/llm_provider.rs create mode 100644 imphnen-dimentorin/src/ai_agent/infrastructure/mod.rs create mode 100644 imphnen-dimentorin/src/ai_agent/infrastructure/qdrant_rag_repository.rs create mode 100644 imphnen-dimentorin/src/ai_agent/mod.rs diff --git a/imphnen-dimentorin/src/ai_agent/application/mod.rs b/imphnen-dimentorin/src/ai_agent/application/mod.rs new file mode 100644 index 0000000..8c98e4c --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/application/mod.rs @@ -0,0 +1,3 @@ +pub mod rag_service; + +pub use rag_service::RagServiceImpl; diff --git a/imphnen-dimentorin/src/ai_agent/application/rag_service.rs b/imphnen-dimentorin/src/ai_agent/application/rag_service.rs new file mode 100644 index 0000000..5ace7a8 --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/application/rag_service.rs @@ -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, + db: DatabaseConnection, +} + +impl RagServiceImpl { + pub fn new(repo: Arc, db: DatabaseConnection) -> Self { + Self { repo, db } + } + + fn split_chunks(title: &str, content: &str) -> Vec { + 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 { + 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 { + self.index_entity(material_id).await + } + + async fn reindex_all(&self) -> Result { + 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 { + 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 = 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 }) + } +} diff --git a/imphnen-dimentorin/src/ai_agent/domain/chat_types.rs b/imphnen-dimentorin/src/ai_agent/domain/chat_types.rs new file mode 100644 index 0000000..63e2753 --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/domain/chat_types.rs @@ -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, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ChatResponse { + pub answer: String, + pub sources: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RagSource { + pub material_id: Uuid, + pub title: String, + pub score: f32, + pub snippet: String, +} diff --git a/imphnen-dimentorin/src/ai_agent/domain/mod.rs b/imphnen-dimentorin/src/ai_agent/domain/mod.rs new file mode 100644 index 0000000..d47d440 --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/domain/mod.rs @@ -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; diff --git a/imphnen-dimentorin/src/ai_agent/domain/rag_document.rs b/imphnen-dimentorin/src/ai_agent/domain/rag_document.rs new file mode 100644 index 0000000..c58ef78 --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/domain/rag_document.rs @@ -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, +} diff --git a/imphnen-dimentorin/src/ai_agent/domain/repository.rs b/imphnen-dimentorin/src/ai_agent/domain/repository.rs new file mode 100644 index 0000000..c879987 --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/domain/repository.rs @@ -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, + ) -> Result<(), AppError>; + /// Search the vector store for the closest chunks to `embedding`. + async fn search( + &self, + embedding: Vec, + limit: u64, + material_id: Option, + ) -> Result, AppError>; + /// Remove all chunks for a material (re-index support). + async fn delete_material(&self, material_id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-dimentorin/src/ai_agent/domain/service.rs b/imphnen-dimentorin/src/ai_agent/domain/service.rs new file mode 100644 index 0000000..d100815 --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/domain/service.rs @@ -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; + /// Re-index all published materials (full refresh). + async fn reindex_all(&self) -> Result; + /// Retrieve context + generate answer for a question. + async fn chat(&self, request: ChatRequest) -> Result; +} diff --git a/imphnen-dimentorin/src/ai_agent/infrastructure/http/dto.rs b/imphnen-dimentorin/src/ai_agent/infrastructure/http/dto.rs new file mode 100644 index 0000000..14a7995 --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/infrastructure/http/dto.rs @@ -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, +} + +impl ZodValidate for ChatRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + 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, +} + +#[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, +} diff --git a/imphnen-dimentorin/src/ai_agent/infrastructure/http/handlers.rs b/imphnen-dimentorin/src/ai_agent/infrastructure/http/handlers.rs new file mode 100644 index 0000000..82a8a2f --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/infrastructure/http/handlers.rs @@ -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>, + ValidatedJson(body): ValidatedJson, +) -> Result { + 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>, + Path(id): Path, +) -> Result { + 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>, +) -> Result { + let indexed = service.reindex_all().await?; + Ok(ApiSuccess(IndexResultDto { + material_id: Uuid::nil(), + chunks_indexed: indexed, + })) +} diff --git a/imphnen-dimentorin/src/ai_agent/infrastructure/http/mod.rs b/imphnen-dimentorin/src/ai_agent/infrastructure/http/mod.rs new file mode 100644 index 0000000..1e75d0b --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/infrastructure/http/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod handlers; +pub mod routes; \ No newline at end of file diff --git a/imphnen-dimentorin/src/ai_agent/infrastructure/http/routes.rs b/imphnen-dimentorin/src/ai_agent/infrastructure/http/routes.rs new file mode 100644 index 0000000..212d97a --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/infrastructure/http/routes.rs @@ -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) -> Router { + let repo = Arc::new(QdrantRagRepository::new()); + let service: Arc = 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)) +} diff --git a/imphnen-dimentorin/src/ai_agent/infrastructure/llm_provider.rs b/imphnen-dimentorin/src/ai_agent/infrastructure/llm_provider.rs new file mode 100644 index 0000000..17d2ea4 --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/infrastructure/llm_provider.rs @@ -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, 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::>(); + 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 { + 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::(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::(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 + ))) +} diff --git a/imphnen-dimentorin/src/ai_agent/infrastructure/mod.rs b/imphnen-dimentorin/src/ai_agent/infrastructure/mod.rs new file mode 100644 index 0000000..0488e47 --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/infrastructure/mod.rs @@ -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; diff --git a/imphnen-dimentorin/src/ai_agent/infrastructure/qdrant_rag_repository.rs b/imphnen-dimentorin/src/ai_agent/infrastructure/qdrant_rag_repository.rs new file mode 100644 index 0000000..5d81802 --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/infrastructure/qdrant_rag_repository.rs @@ -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, + ) -> 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, + limit: u64, + material_id: Option, + ) -> Result, 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; diff --git a/imphnen-dimentorin/src/ai_agent/mod.rs b/imphnen-dimentorin/src/ai_agent/mod.rs new file mode 100644 index 0000000..cc07b7c --- /dev/null +++ b/imphnen-dimentorin/src/ai_agent/mod.rs @@ -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; diff --git a/imphnen-dimentorin/src/lib.rs b/imphnen-dimentorin/src/lib.rs index 5391cf7..20bf9e5 100644 --- a/imphnen-dimentorin/src/lib.rs +++ b/imphnen-dimentorin/src/lib.rs @@ -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}; diff --git a/imphnen-gateway/src/lib.rs b/imphnen-gateway/src/lib.rs index 806ff8c..07491e9 100644 --- a/imphnen-gateway/src/lib.rs +++ b/imphnen-gateway/src/lib.rs @@ -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))) diff --git a/imphnen-libs/src/environment/mod.rs b/imphnen-libs/src/environment/mod.rs index 9da88d3..8e3a040 100644 --- a/imphnen-libs/src/environment/mod.rs +++ b/imphnen-libs/src/environment/mod.rs @@ -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, } @@ -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 = 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",