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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user