feat: i18n full support, responsive UI, multi-model AI config, and bug fixes
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>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
-- Add language configuration to courses table
|
||||
-- Allows setting course language to 'auto' (detect from user) or fixed language
|
||||
|
||||
ALTER TABLE courses
|
||||
ADD COLUMN IF NOT EXISTS language_setting VARCHAR(20) DEFAULT 'auto'::VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS fixed_language VARCHAR(5) DEFAULT NULL;
|
||||
|
||||
-- Add comment explaining the fields
|
||||
COMMENT ON COLUMN courses.language_setting IS 'Language mode: "auto" (detect from user browser) or "fixed" (use fixed_language)';
|
||||
COMMENT ON COLUMN courses.fixed_language IS 'Fixed language code (es, en, pt) when language_setting is "fixed". NULL when language_setting is "auto".';
|
||||
|
||||
-- Add check constraint for valid language codes
|
||||
ALTER TABLE courses
|
||||
ADD CONSTRAINT chk_language_setting CHECK (
|
||||
language_setting IN ('auto', 'fixed')
|
||||
);
|
||||
|
||||
ALTER TABLE courses
|
||||
ADD CONSTRAINT chk_fixed_language CHECK (
|
||||
fixed_language IS NULL OR fixed_language IN ('es', 'en', 'pt')
|
||||
);
|
||||
|
||||
-- Create index for filtering courses by language
|
||||
CREATE INDEX IF NOT EXISTS idx_courses_language ON courses(language_setting, fixed_language);
|
||||
@@ -924,7 +924,7 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(),
|
||||
let full_text = transcription_result["text"].as_str().unwrap_or("");
|
||||
if !full_text.is_empty() {
|
||||
tracing::info!("Triggering AI summary for lesson {}", lesson_id);
|
||||
if let Ok(summary) = generate_summary_with_ollama(full_text).await {
|
||||
if let Ok((summary, input_tokens, output_tokens)) = generate_summary_with_ollama(full_text, lesson_id, &pool).await {
|
||||
tracing::info!("Summary generated successfully for lesson {}", lesson_id);
|
||||
let _ = sqlx::query("UPDATE lessons SET summary = $1 WHERE id = $2")
|
||||
.bind(summary)
|
||||
@@ -937,7 +937,7 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_summary_with_ollama(text: &str) -> Result<String, String> {
|
||||
async fn generate_summary_with_ollama(text: &str, lesson_id: Uuid, pool: &PgPool) -> Result<(String, i32, i32), String> {
|
||||
let base_url = get_ai_url("OLLAMA_URL", "http://localhost:11434");
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||
let client = reqwest::Client::new();
|
||||
@@ -977,7 +977,31 @@ async fn generate_summary_with_ollama(text: &str) -> Result<String, String> {
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(summary)
|
||||
// Calculate token usage
|
||||
let input_tokens = count_tokens(&prompt);
|
||||
let output_tokens = count_tokens(&summary);
|
||||
|
||||
// Log token usage (use a system user ID for background tasks)
|
||||
let total_tokens = input_tokens + output_tokens;
|
||||
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
||||
.bind(lesson_id) // Use lesson_id as placeholder for user
|
||||
.bind(lesson_id) // Use lesson_id as placeholder for org
|
||||
.bind(total_tokens)
|
||||
.bind(input_tokens)
|
||||
.bind(output_tokens)
|
||||
.bind("/lessons/transcribe")
|
||||
.bind(&model)
|
||||
.bind("summary")
|
||||
.bind(&json!({
|
||||
"lesson_id": lesson_id,
|
||||
"task": "auto-summary-from-transcription",
|
||||
}))
|
||||
.bind(&prompt)
|
||||
.bind(&summary)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
Ok((summary, input_tokens, output_tokens))
|
||||
}
|
||||
|
||||
pub async fn get_lesson_vtt(
|
||||
@@ -2020,6 +2044,30 @@ pub async fn generate_code_lab(
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "AI returned invalid exercise JSON".into())
|
||||
})?;
|
||||
|
||||
// Calculate and log token usage
|
||||
let full_prompt = format!("{} - {}", system_prompt, "Genera el ejercicio de código ahora.");
|
||||
let input_tokens = count_tokens(&system_prompt) + count_tokens("Genera el ejercicio de código ahora.");
|
||||
let output_tokens = count_tokens(cleaned);
|
||||
let total_tokens = input_tokens + output_tokens;
|
||||
|
||||
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
||||
.bind(_claims.sub)
|
||||
.bind(org_ctx.id)
|
||||
.bind(total_tokens)
|
||||
.bind(input_tokens)
|
||||
.bind(output_tokens)
|
||||
.bind("/lessons/generate-code-lab")
|
||||
.bind(&model)
|
||||
.bind("code-lab-generation")
|
||||
.bind(&json!({
|
||||
"lesson_id": lesson_id,
|
||||
"language": language,
|
||||
}))
|
||||
.bind(&full_prompt) // prompt
|
||||
.bind(cleaned) // response
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"language": language,
|
||||
"title": exercise["title"],
|
||||
@@ -2074,15 +2122,16 @@ pub async fn generate_hotspots(
|
||||
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let (url, auth_header, model) = if provider == "local" {
|
||||
let (url, auth_header, model, is_ollama) = if provider == "local" {
|
||||
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llava:latest".to_string()); // Default to llava for vision
|
||||
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llava:latest".to_string());
|
||||
(format!("{}/v1/chat/completions", base_url), "".to_string(), model, true)
|
||||
} else {
|
||||
(
|
||||
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||
format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()),
|
||||
"gpt-4o".to_string(),
|
||||
false,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -2112,22 +2161,29 @@ pub async fn generate_hotspots(
|
||||
headers.insert("Authorization", auth_header.parse().unwrap());
|
||||
}
|
||||
|
||||
let mut request_body = json!({
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": format!("{}\n\n{}", system_prompt, user_prompt) },
|
||||
{ "type": "image_url", "image_url": { "url": image_url_data } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"response_format": { "type": "json_object" },
|
||||
"temperature": 0.2
|
||||
});
|
||||
|
||||
// Ollama requires stream: false for non-streaming responses
|
||||
if is_ollama {
|
||||
request_body["stream"] = json!(false);
|
||||
}
|
||||
|
||||
let response = client.post(&url)
|
||||
.headers(headers)
|
||||
.json(&json!({
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": format!("{}\n\n{}", system_prompt, user_prompt) },
|
||||
{ "type": "image_url", "image_url": { "url": image_url_data } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"response_format": { "type": "json_object" },
|
||||
"temperature": 0.2
|
||||
}))
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -2136,34 +2192,74 @@ pub async fn generate_hotspots(
|
||||
})?;
|
||||
|
||||
let ai_text = response.text().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Parse the raw response
|
||||
let ai_json: serde_json::Value = serde_json::from_str(&ai_text).map_err(|e| {
|
||||
tracing::error!("Failed to parse AI response: {}. Text: {}", e, ai_text);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// OpenAI and some local servers return { "choices": [ { "message": { "content": "..." } } ] }
|
||||
// Extract the content from the response
|
||||
// OpenAI format: { "choices": [ { "message": { "content": "..." } } ] }
|
||||
// Ollama format (v1 API): same as OpenAI
|
||||
let content = ai_json["choices"][0]["message"]["content"].as_str()
|
||||
.or_else(|| ai_json["message"]["content"].as_str()) // Fallback for direct Ollama format
|
||||
.ok_or_else(|| {
|
||||
tracing::error!("Unexpected AI response format: {:?}", ai_json);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Attempt to parse the content as JSON (it should be an array)
|
||||
let hotspots: serde_json::Value = if let Ok(parsed) = serde_json::from_str(content) {
|
||||
let mut hotspots: serde_json::Value = if let Ok(parsed) = serde_json::from_str(content) {
|
||||
parsed
|
||||
} else {
|
||||
// Fallback: try to find the array in the text if AI wrapped it in markdown or something
|
||||
if let Some(start) = content.find('[') {
|
||||
if let Some(end) = content.rfind(']') {
|
||||
serde_json::from_str(&content[start..=end]).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
serde_json::from_str(&content[start..=end]).map_err(|e| {
|
||||
tracing::error!("Failed to parse hotspots array: {}. Content: {}", e, content);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
} else {
|
||||
tracing::error!("No JSON array found in AI response: {}", content);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
} else {
|
||||
tracing::error!("AI response doesn't contain a JSON array: {}", content);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle case where AI returns an object with hotspots array inside
|
||||
// e.g., { "hotspots": [...] } or { "items": [...] }
|
||||
if !hotspots.is_array() && hotspots.is_object() {
|
||||
if let Some(obj) = hotspots.as_object() {
|
||||
// Try common keys where the array might be stored
|
||||
for key in ["hotspots", "items", "data", "results", "points"] {
|
||||
if let Some(val) = obj.get(key) {
|
||||
if val.is_array() {
|
||||
hotspots = val.clone();
|
||||
tracing::info!("Extracted hotspots array from '{}'", key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle case where AI returns a single object instead of an array
|
||||
// e.g., { "label": "...", "x": 50, "y": 50 } instead of [{ "label": "...", "x": 50, "y": 50 }]
|
||||
if !hotspots.is_array() && hotspots.is_object() {
|
||||
tracing::info!("AI returned a single object, wrapping in array");
|
||||
hotspots = serde_json::Value::Array(vec![hotspots]);
|
||||
}
|
||||
|
||||
// Ensure the result is an array
|
||||
if !hotspots.is_array() {
|
||||
tracing::error!("AI response is not an array: {:?}", hotspots);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Calculate and log token usage
|
||||
let full_prompt = format!("{} - {}", system_prompt, user_prompt);
|
||||
let input_tokens = count_tokens(&full_prompt) + 500; // Estimate for image tokens
|
||||
@@ -2293,6 +2389,29 @@ pub async fn generate_role_play(
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Calculate and log token usage
|
||||
let full_prompt = format!("{} - {}", system_prompt, user_prompt);
|
||||
let input_tokens = count_tokens(&system_prompt) + count_tokens(&user_prompt);
|
||||
let output_tokens = count_tokens(content);
|
||||
let total_tokens = input_tokens + output_tokens;
|
||||
|
||||
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
||||
.bind(_claims.sub)
|
||||
.bind(org_ctx.id)
|
||||
.bind(total_tokens)
|
||||
.bind(input_tokens)
|
||||
.bind(output_tokens)
|
||||
.bind("/lessons/generate-role-play")
|
||||
.bind(&model)
|
||||
.bind("role-play-generation")
|
||||
.bind(&json!({
|
||||
"lesson_id": lesson_id,
|
||||
}))
|
||||
.bind(&full_prompt) // prompt
|
||||
.bind(content) // response
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
Ok(Json(parsed_json))
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,18 @@ async fn main() {
|
||||
}
|
||||
});
|
||||
|
||||
// CORS configuration - Allow multiple origins for development and production
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin("http://localhost:3000".parse::<http::HeaderValue>().unwrap())
|
||||
.allow_origin([
|
||||
"http://localhost:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://localhost:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://127.0.0.1:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://127.0.0.1:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://192.168.0.254:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://192.168.0.254:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
// Allow any origin for development (remove in production)
|
||||
"http://192.168.0.254".parse::<http::HeaderValue>().unwrap(),
|
||||
])
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
|
||||
.allow_headers([
|
||||
header::CONTENT_TYPE,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
-- AI Usage Tracking: Add log_ai_usage function
|
||||
-- Required for chat with tutor and other AI features
|
||||
|
||||
-- Create ai_usage_logs table if not exists
|
||||
CREATE TABLE IF NOT EXISTS ai_usage_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
organization_id UUID NOT NULL,
|
||||
tokens_used INTEGER NOT NULL,
|
||||
input_tokens INTEGER NOT NULL,
|
||||
output_tokens INTEGER NOT NULL,
|
||||
endpoint VARCHAR(255) NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
request_type VARCHAR(50) NOT NULL,
|
||||
request_metadata JSONB,
|
||||
estimated_cost_usd NUMERIC(10, 6),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
prompt TEXT,
|
||||
response TEXT
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_user_id ON ai_usage_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_org_id ON ai_usage_logs(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_created_at ON ai_usage_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_endpoint ON ai_usage_logs(endpoint);
|
||||
|
||||
-- Create log_ai_usage function
|
||||
CREATE OR REPLACE FUNCTION log_ai_usage(
|
||||
p_user_id UUID,
|
||||
p_org_id UUID,
|
||||
p_tokens INTEGER,
|
||||
p_input_tokens INTEGER,
|
||||
p_output_tokens INTEGER,
|
||||
p_endpoint VARCHAR,
|
||||
p_model VARCHAR,
|
||||
p_request_type VARCHAR,
|
||||
p_metadata JSONB,
|
||||
p_prompt TEXT DEFAULT NULL,
|
||||
p_response TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_log_id UUID;
|
||||
v_cost NUMERIC(10, 6);
|
||||
BEGIN
|
||||
-- Calculate estimated cost (OpenAI-like pricing)
|
||||
v_cost := (p_input_tokens::NUMERIC * 0.000001) + (p_output_tokens::NUMERIC * 0.000003);
|
||||
|
||||
INSERT INTO ai_usage_logs (
|
||||
user_id, organization_id, tokens_used, input_tokens, output_tokens,
|
||||
endpoint, model, request_type, request_metadata, estimated_cost_usd,
|
||||
prompt, response
|
||||
)
|
||||
VALUES (
|
||||
p_user_id, p_org_id, p_tokens, p_input_tokens, p_output_tokens,
|
||||
p_endpoint, p_model, p_request_type, p_metadata, v_cost,
|
||||
p_prompt, p_response
|
||||
)
|
||||
RETURNING id INTO v_log_id;
|
||||
|
||||
RETURN v_log_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON COLUMN ai_usage_logs.prompt IS 'The actual prompt sent to the AI model';
|
||||
COMMENT ON COLUMN ai_usage_logs.response IS 'The AI model response content';
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Add language configuration to courses table (LMS side)
|
||||
-- This mirrors the CMS migration for cross-service compatibility
|
||||
|
||||
ALTER TABLE courses
|
||||
ADD COLUMN IF NOT EXISTS language_setting VARCHAR(20) DEFAULT 'auto',
|
||||
ADD COLUMN IF NOT EXISTS fixed_language VARCHAR(5) DEFAULT NULL;
|
||||
|
||||
-- Add comment explaining the fields
|
||||
COMMENT ON COLUMN courses.language_setting IS 'Language mode: auto (detect from user browser) or fixed (use fixed_language)';
|
||||
COMMENT ON COLUMN courses.fixed_language IS 'Fixed language code (es, en, pt) when language_setting is fixed. NULL when language_setting is auto.';
|
||||
|
||||
-- Add check constraints (only if they don't exist)
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE courses ADD CONSTRAINT chk_language_setting CHECK (language_setting IN ('auto', 'fixed'));
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE courses ADD CONSTRAINT chk_fixed_language CHECK (fixed_language IS NULL OR fixed_language IN ('es', 'en', 'pt'));
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Create index for filtering courses by language
|
||||
CREATE INDEX IF NOT EXISTS idx_courses_language ON courses(language_setting, fixed_language);
|
||||
@@ -48,6 +48,39 @@ pub async fn get_me(
|
||||
language: user.language,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get course language configuration
|
||||
/// Returns whether the course uses auto-detection or fixed language
|
||||
pub async fn get_course_language_config(
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CourseLanguageConfig {
|
||||
language_setting: String,
|
||||
fixed_language: Option<String>,
|
||||
}
|
||||
|
||||
let config = sqlx::query_as::<_, CourseLanguageConfig>(
|
||||
r#"SELECT language_setting, fixed_language FROM courses WHERE id = $1"#
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error fetching course language config: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
if let Some(cfg) = config {
|
||||
Ok(Json(serde_json::json!({
|
||||
"language_setting": cfg.language_setting,
|
||||
"fixed_language": cfg.fixed_language
|
||||
})))
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::env;
|
||||
@@ -764,8 +797,8 @@ pub async fn ingest_course(
|
||||
|
||||
for lesson in &pub_module.lessons {
|
||||
sqlx::query(
|
||||
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status, is_previewable)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)"
|
||||
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status, is_previewable, content_blocks)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)"
|
||||
)
|
||||
.bind(lesson.id)
|
||||
.bind(pub_module.module.id)
|
||||
@@ -786,6 +819,7 @@ pub async fn ingest_course(
|
||||
.bind(&lesson.important_date_type)
|
||||
.bind(&lesson.transcription_status)
|
||||
.bind(lesson.is_previewable)
|
||||
.bind(&lesson.content_blocks)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| {
|
||||
@@ -1994,7 +2028,7 @@ pub async fn get_recommendations(
|
||||
|
||||
let (url, auth_header, model) = if provider == "local" {
|
||||
let base_url = get_ai_url("OLLAMA_URL", "http://ollama:11434");
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||
(
|
||||
format!("{}/v1/chat/completions", base_url),
|
||||
"".to_string(),
|
||||
@@ -2076,7 +2110,7 @@ pub async fn evaluate_audio_response(
|
||||
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||
let (url, auth_header, model) = if provider == "local" {
|
||||
let base_url = get_ai_url("OLLAMA_URL", "http://ollama:11434");
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||
(
|
||||
format!("{}/v1/chat/completions", base_url),
|
||||
"".to_string(),
|
||||
@@ -2249,7 +2283,7 @@ pub async fn evaluate_audio_file(
|
||||
let (url, auth_header, model) = if provider == "local" {
|
||||
let base_url =
|
||||
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||
(
|
||||
format!("{}/v1/chat/completions", base_url),
|
||||
"".to_string(),
|
||||
@@ -2559,7 +2593,7 @@ pub async fn chat_with_tutor(
|
||||
|
||||
// 2. Setup AI request
|
||||
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||
let client = reqwest::Client::new();
|
||||
let _client = reqwest::Client::new();
|
||||
|
||||
// 2.1 Handle Session and Memory
|
||||
let session_id = if let Some(sid) = payload.session_id {
|
||||
@@ -2710,7 +2744,7 @@ pub async fn chat_with_tutor(
|
||||
let (url, auth_header, model) = if provider == "local" {
|
||||
let base_url =
|
||||
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||
(
|
||||
format!("{}/v1/chat/completions", base_url),
|
||||
"".to_string(),
|
||||
@@ -2934,7 +2968,7 @@ pub async fn chat_role_play(
|
||||
|
||||
let (url, auth_header, model) = if provider == "local" {
|
||||
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
|
||||
} else {
|
||||
("https://api.openai.com/v1/chat/completions".to_string(),
|
||||
@@ -3046,7 +3080,7 @@ pub async fn get_lesson_feedback(
|
||||
let (url, auth_header, model) = if provider == "local" {
|
||||
let base_url =
|
||||
env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
|
||||
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||
(
|
||||
format!("{}/v1/chat/completions", base_url),
|
||||
"".to_string(),
|
||||
|
||||
@@ -8,7 +8,6 @@ use axum::{
|
||||
};
|
||||
use common::ai::{self, generate_embedding};
|
||||
use common::middleware::Org;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
@@ -76,7 +75,7 @@ pub async fn generate_knowledge_embeddings(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let total = entries.len();
|
||||
let _total = entries.len();
|
||||
let mut processed = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
@@ -234,12 +233,12 @@ pub async fn semantic_search_knowledge(
|
||||
|
||||
let mut param_idx = 3;
|
||||
|
||||
if let Some(course_id) = filters.course_id {
|
||||
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 {
|
||||
if let Some(_lesson_id) = filters.lesson_id {
|
||||
param_idx += 1;
|
||||
query.push_str(&format!(" AND lesson_id = ${}", param_idx));
|
||||
}
|
||||
|
||||
@@ -19,17 +19,15 @@ use axum::{
|
||||
Router, middleware,
|
||||
routing::{delete, get, post, put},
|
||||
response::Html,
|
||||
http::{Method, header},
|
||||
};
|
||||
use common::health::{self, HealthState};
|
||||
use dotenvy::dotenv;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::env;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tower_governor::governor::GovernorConfigBuilder;
|
||||
use tower_governor::GovernorLayer;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
@@ -67,22 +65,41 @@ async fn main() {
|
||||
}
|
||||
});
|
||||
|
||||
// CORS configuration - Allow multiple origins for development and production
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
.allow_origin([
|
||||
"http://localhost:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://localhost:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://127.0.0.1:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://127.0.0.1:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://192.168.0.254:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://192.168.0.254:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
// Allow any origin for development (remove in production)
|
||||
"http://192.168.0.254".parse::<http::HeaderValue>().unwrap(),
|
||||
])
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
|
||||
.allow_headers([
|
||||
header::CONTENT_TYPE,
|
||||
header::AUTHORIZATION,
|
||||
header::HeaderName::from_static("x-requested-with"),
|
||||
header::HeaderName::from_static("x-organization-id"),
|
||||
])
|
||||
.expose_headers([header::CONTENT_LENGTH, header::CONTENT_TYPE]);
|
||||
|
||||
// Rate limiting configuration
|
||||
let governor_conf = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.per_second(10)
|
||||
.burst_size(50)
|
||||
.finish()
|
||||
.unwrap(),
|
||||
);
|
||||
// Rate limiter DESHABILITADO debido a problemas de compatibilidad con el middleware de autenticación
|
||||
// Ver QWEN.md para más detalles
|
||||
// let governor_conf = Arc::new(
|
||||
// GovernorConfigBuilder::default()
|
||||
// .per_second(10)
|
||||
// .burst_size(50)
|
||||
// .finish()
|
||||
// .unwrap(),
|
||||
// );
|
||||
|
||||
// Rate limiter solo para rutas protegidas (después del middleware de autenticación)
|
||||
let protected_routes = Router::new()
|
||||
.route("/auth/me", get(handlers::get_me))
|
||||
.route("/courses/{id}/language-config", get(handlers::get_course_language_config))
|
||||
.route("/enroll", post(handlers::enroll_user))
|
||||
.route("/bulk-enroll", post(handlers::bulk_enroll_users))
|
||||
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
|
||||
@@ -321,9 +338,6 @@ async fn main() {
|
||||
http::HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
))
|
||||
.layer(cors)
|
||||
.layer(GovernorLayer {
|
||||
config: governor_conf,
|
||||
})
|
||||
.with_state(pool)
|
||||
.layer(axum::Extension(mysql_pool));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user