2ff06ee7ae
Major Features:
- Internationalization (i18n) with auto-detection for ES/EN/PT
- Mobile-first responsive design for Studio and Experience
- Multi-model AI configuration (llama3.2:3b, qwen3.5:9b, gpt-oss:latest)
- Course language configuration (auto-detect or fixed per course)
Backend Changes:
- shared/common: ModelType enum for intelligent model selection
- LMS: log_ai_usage function migration (fix chat tutor 500 error)
- LMS/CMS: course language config fields (language_setting, fixed_language)
- LMS: /courses/{id}/language-config endpoint for language detection
Frontend Changes:
- Experience: Enhanced i18n with browser language detection
- Experience: Audio recording with HTTPS check and error handling
- Studio: Memory game with unique pair IDs and debug logging
- Studio: Expanded translations (250+ keys for ES, EN, PT)
- Both: Language selector in headers (mobile responsive)
Documentation:
- AI_MODELS_CONFIG.md: Multi-model configuration guide
- RESPONSIVIDAD_GUIA.md: Mobile-first design patterns
- I18N_RESPONSIVIDAD_IMPLEMENTACION.md: Implementation details
- DEBUG_AUDIO_RECORDING.md: Audio troubleshooting guide
- DEBUG_MEMORY_GAME.md: Memory game debugging steps
Bug Fixes:
- Fix chat tutor 500 error (missing log_ai_usage function)
- Fix audio recording (HTTPS check, browser compatibility)
- Fix memory game pair IDs (unique ID generation)
- Fix HotspotBlock TypeScript errors
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
287 lines
8.8 KiB
Rust
287 lines
8.8 KiB
Rust
//! Handlers for PGVector embeddings in Knowledge Base (LMS)
|
|
//! Enables semantic search for AI tutor chat with RAG
|
|
|
|
use axum::{
|
|
Json,
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
};
|
|
use common::ai::{self, generate_embedding};
|
|
use common::middleware::Org;
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
// ==================== Query Parameters ====================
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct KnowledgeSearchFilters {
|
|
pub query: String,
|
|
pub course_id: Option<Uuid>,
|
|
pub lesson_id: Option<Uuid>,
|
|
pub limit: Option<i32>,
|
|
pub threshold: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct KnowledgeSearchResult {
|
|
pub id: Uuid,
|
|
pub course_id: Uuid,
|
|
pub lesson_id: Option<Uuid>,
|
|
pub block_id: Option<Uuid>,
|
|
pub content_chunk: String,
|
|
pub similarity: f64, // PostgreSQL vector similarity returns double precision
|
|
pub metadata: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct GenerateKnowledgeEmbeddingsResult {
|
|
pub processed: i32,
|
|
pub failed: i32,
|
|
pub duration_ms: u64,
|
|
}
|
|
|
|
// ==================== Generate Embeddings ====================
|
|
|
|
/// POST /api/knowledge-base/embeddings/generate - Generate embeddings for all knowledge base entries
|
|
pub async fn generate_knowledge_embeddings(
|
|
Org(org_ctx): Org,
|
|
State(pool): State<PgPool>,
|
|
) -> Result<Json<GenerateKnowledgeEmbeddingsResult>, (StatusCode, String)> {
|
|
let start = std::time::Instant::now();
|
|
|
|
// Create client that accepts invalid certificates (for dev with self-signed certs)
|
|
let client = reqwest::Client::builder()
|
|
.danger_accept_invalid_certs(true)
|
|
.danger_accept_invalid_hostnames(true)
|
|
.build()
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
|
|
|
|
let ollama_url = ai::get_ollama_url();
|
|
let model = ai::get_embedding_model();
|
|
|
|
// Get knowledge base entries without embeddings
|
|
let entries: Vec<KnowledgeBaseEntry> = sqlx::query_as(
|
|
r#"
|
|
SELECT * FROM knowledge_base
|
|
WHERE organization_id = $1
|
|
AND (embedding IS NULL OR embedding_updated_at IS NULL)
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
"#
|
|
)
|
|
.bind(org_ctx.id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let _total = entries.len();
|
|
let mut processed = 0;
|
|
let mut failed = 0;
|
|
|
|
for entry in entries {
|
|
// Generate embedding from content chunk
|
|
match generate_embedding(&client, &ollama_url, &model, &entry.content_chunk).await {
|
|
Ok(response) => {
|
|
let pgvector = ai::embedding_to_pgvector(&response.embedding);
|
|
|
|
// Update entry with embedding
|
|
let result: Result<(i64,), sqlx::Error> = sqlx::query_as(
|
|
r#"
|
|
UPDATE knowledge_base
|
|
SET embedding = $1::vector,
|
|
embedding_updated_at = NOW()
|
|
WHERE id = $2
|
|
RETURNING 1
|
|
"#
|
|
)
|
|
.bind(&pgvector)
|
|
.bind(entry.id)
|
|
.fetch_one(&pool)
|
|
.await;
|
|
|
|
if result.is_ok() {
|
|
processed += 1;
|
|
} else {
|
|
failed += 1;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
"Failed to generate embedding for knowledge entry {}: {}",
|
|
entry.id,
|
|
e
|
|
);
|
|
failed += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
let duration_ms = start.elapsed().as_millis() as u64;
|
|
|
|
tracing::info!(
|
|
"Generated knowledge embeddings: {} processed, {} failed in {}ms",
|
|
processed,
|
|
failed,
|
|
duration_ms
|
|
);
|
|
|
|
Ok(Json(GenerateKnowledgeEmbeddingsResult {
|
|
processed,
|
|
failed,
|
|
duration_ms,
|
|
}))
|
|
}
|
|
|
|
/// POST /api/knowledge-base/{id}/embedding/regenerate - Regenerate embedding for a specific entry
|
|
pub async fn regenerate_knowledge_embedding(
|
|
Org(org_ctx): Org,
|
|
Path(entry_id): Path<Uuid>,
|
|
State(pool): State<PgPool>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
// Create client that accepts invalid certificates
|
|
let client = reqwest::Client::builder()
|
|
.danger_accept_invalid_certs(true)
|
|
.danger_accept_invalid_hostnames(true)
|
|
.build()
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
|
|
|
|
let ollama_url = ai::get_ollama_url();
|
|
let model = ai::get_embedding_model();
|
|
|
|
// Get entry
|
|
let entry: KnowledgeBaseEntry = sqlx::query_as(
|
|
"SELECT * FROM knowledge_base WHERE id = $1 AND organization_id = $2"
|
|
)
|
|
.bind(entry_id)
|
|
.bind(org_ctx.id)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
.ok_or((StatusCode::NOT_FOUND, "Knowledge base entry not found".to_string()))?;
|
|
|
|
// Generate embedding
|
|
let response = generate_embedding(&client, &ollama_url, &model, &entry.content_chunk)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?;
|
|
|
|
let pgvector = ai::embedding_to_pgvector(&response.embedding);
|
|
|
|
// Update entry
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE knowledge_base
|
|
SET embedding = $1::vector,
|
|
embedding_updated_at = NOW()
|
|
WHERE id = $2
|
|
"#
|
|
)
|
|
.bind(&pgvector)
|
|
.bind(entry_id)
|
|
.execute(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
// ==================== Semantic Search ====================
|
|
|
|
/// GET /api/knowledge-base/semantic-search - Search knowledge base by semantic similarity
|
|
pub async fn semantic_search_knowledge(
|
|
Org(org_ctx): Org,
|
|
State(pool): State<PgPool>,
|
|
Query(filters): Query<KnowledgeSearchFilters>,
|
|
) -> Result<Json<Vec<KnowledgeSearchResult>>, (StatusCode, String)> {
|
|
// Create client that accepts invalid certificates
|
|
let client = reqwest::Client::builder()
|
|
.danger_accept_invalid_certs(true)
|
|
.danger_accept_invalid_hostnames(true)
|
|
.build()
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
|
|
|
|
let ollama_url = ai::get_ollama_url();
|
|
let model = ai::get_embedding_model();
|
|
|
|
// Generate embedding for query
|
|
let embedding_response = generate_embedding(&client, &ollama_url, &model, &filters.query)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?;
|
|
|
|
let pgvector = ai::embedding_to_pgvector(&embedding_response.embedding);
|
|
|
|
let limit = filters.limit.unwrap_or(10);
|
|
let threshold = filters.threshold.unwrap_or(0.5);
|
|
|
|
// Build query with optional filters
|
|
let mut query = String::from(
|
|
r#"
|
|
SELECT
|
|
id,
|
|
course_id,
|
|
lesson_id,
|
|
block_id,
|
|
content_chunk,
|
|
1 - (embedding <=> $1::vector) AS similarity,
|
|
metadata
|
|
FROM knowledge_base
|
|
WHERE organization_id = $2
|
|
AND embedding IS NOT NULL
|
|
AND 1 - (embedding <=> $1::vector) >= $3
|
|
"#
|
|
);
|
|
|
|
let mut param_idx = 3;
|
|
|
|
if let Some(_course_id) = filters.course_id {
|
|
param_idx += 1;
|
|
query.push_str(&format!(" AND course_id = ${}", param_idx));
|
|
}
|
|
|
|
if let Some(_lesson_id) = filters.lesson_id {
|
|
param_idx += 1;
|
|
query.push_str(&format!(" AND lesson_id = ${}", param_idx));
|
|
}
|
|
|
|
param_idx += 1;
|
|
query.push_str(&format!(" ORDER BY embedding <=> $1::vector LIMIT ${}", param_idx));
|
|
|
|
let mut sql_query = sqlx::query_as::<_, KnowledgeSearchResult>(&query)
|
|
.bind(&pgvector)
|
|
.bind(org_ctx.id)
|
|
.bind(threshold);
|
|
|
|
if let Some(course_id) = filters.course_id {
|
|
sql_query = sql_query.bind(course_id);
|
|
}
|
|
|
|
if let Some(lesson_id) = filters.lesson_id {
|
|
sql_query = sql_query.bind(lesson_id);
|
|
}
|
|
|
|
sql_query = sql_query.bind(limit);
|
|
|
|
let results = sql_query
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(Json(results))
|
|
}
|
|
|
|
// ==================== Helper Structs ====================
|
|
|
|
#[derive(Debug, sqlx::FromRow, Clone)]
|
|
struct KnowledgeBaseEntry {
|
|
id: Uuid,
|
|
organization_id: Uuid,
|
|
course_id: Uuid,
|
|
lesson_id: Option<Uuid>,
|
|
block_id: Option<Uuid>,
|
|
content_chunk: String,
|
|
chunk_order: i32,
|
|
metadata: Option<serde_json::Value>,
|
|
#[allow(dead_code)]
|
|
created_at: chrono::DateTime<chrono::Utc>,
|
|
}
|