Files
imphnen-backend-service/imphnen-dimentorin/src/ai_agent/application/rag_service.rs
T
asepharyana 164c1860da 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
2026-08-05 17:15:11 +07:00

171 lines
4.8 KiB
Rust

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 })
}
}