feat: SAM integration, deployment scripts, and audio response enhancements

- Add SAM (Sistema de Administración Académica) integration with sync endpoints
- Add deployment automation (deploy.sh, remote-setup.sh, setup-nginx-ssl.sh)
- Add nginx proxy configuration for SSL with Let's Encrypt
- Add audio response support for student lessons (migrations, handlers)
- Add audio evaluations admin page
- Update CORS to support wildcard subdomains for norteamericano.cl
- Add comprehensive deployment documentation (DESPLIEGUE.md, ManualDeConfiguracion.md)
- Update docker-compose.yml with nginx-proxy and acme-companion services
- Remove outdated documentation files

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-27 09:20:23 -03:00
parent 995065df4f
commit e4866c6dee
50 changed files with 4866 additions and 5371 deletions
+409 -4
View File
@@ -6,6 +6,7 @@ use axum::{
Extension,
};
use bcrypt::{DEFAULT_COST, hash, verify};
use chrono::{DateTime, Utc};
use common::auth::{Claims, create_jwt};
use common::middleware::Org;
use common::models::{
@@ -15,6 +16,7 @@ use common::models::{
};
use crate::external_db::MySqlPool;
use serde_json::json;
use base64::Engine;
// Simple token counter (approximate: 1 token ≈ 4 characters in English, ~3-5 in Spanish)
fn count_tokens(text: &str) -> i32 {
@@ -2103,6 +2105,7 @@ pub async fn get_recommendations(
pub async fn evaluate_audio_response(
Org(_org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<AudioGradingPayload>,
) -> Result<Json<AudioGradingResponse>, (StatusCode, String)> {
// Check token limit before proceeding (estimate 1500 tokens for audio evaluation)
@@ -2182,14 +2185,20 @@ pub async fn evaluate_audio_response(
}
pub async fn evaluate_audio_file(
Org(_org_ctx): Org,
_claims: Claims,
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
mut multipart: Multipart,
) -> Result<Json<AudioGradingResponse>, (StatusCode, String)> {
let mut lesson_id_str = String::new();
let mut block_id_str = String::new();
let mut prompt = String::new();
let mut keywords_str = String::new();
let mut audio_data = Vec::new();
let mut filename = "audio.webm".to_string();
let mut duration_seconds: Option<i32> = None;
tracing::info!("Received audio evaluation request from user: {}", claims.sub);
while let Some(field) = multipart
.next_field()
@@ -2198,8 +2207,24 @@ pub async fn evaluate_audio_file(
{
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"prompt" => prompt = field.text().await.unwrap_or_default(),
"lesson_id" => {
lesson_id_str = field.text().await.unwrap_or_default();
tracing::info!("Received lesson_id: {}", lesson_id_str);
}
"block_id" => {
block_id_str = field.text().await.unwrap_or_default();
tracing::info!("Received block_id: {}", block_id_str);
}
"prompt" => {
prompt = field.text().await.unwrap_or_default();
tracing::info!("Received prompt: {}", prompt);
}
"keywords" => keywords_str = field.text().await.unwrap_or_default(),
"duration" => {
if let Ok(d) = field.text().await.unwrap_or_default().parse() {
duration_seconds = Some(d);
}
}
"file" => {
filename = field.file_name().unwrap_or("audio.webm").to_string();
audio_data = field
@@ -2207,18 +2232,35 @@ pub async fn evaluate_audio_file(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.to_vec();
tracing::info!("Received audio file: {} bytes", audio_data.len());
}
_ => {}
}
}
if audio_data.is_empty() {
tracing::error!("No audio data received");
return Err((
StatusCode::BAD_REQUEST,
"No se proporcionó ningún archivo de audio".into(),
));
}
// Parse lesson_id and block_id
let lesson_id = Uuid::parse_str(&lesson_id_str)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid lesson_id".into()))?;
let block_id = Uuid::parse_str(&block_id_str)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid block_id".into()))?;
// Get course_id from lesson (lessons has module_id, modules has course_id)
let course_id: Uuid = sqlx::query_scalar(
"SELECT m.course_id FROM lessons l JOIN modules m ON l.module_id = m.id WHERE l.id = $1"
)
.bind(lesson_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?;
// 1. Send to Whisper
let whisper_url =
env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
@@ -2227,7 +2269,7 @@ pub async fn evaluate_audio_file(
let form = reqwest::multipart::Form::new()
.part(
"file",
reqwest::multipart::Part::bytes(audio_data).file_name(filename),
reqwest::multipart::Part::bytes(audio_data.clone()).file_name(filename),
)
.text("model", "whisper-1")
.text("response_format", "json");
@@ -2355,9 +2397,372 @@ pub async fn evaluate_audio_file(
})
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Mapping failed: {}", e)))?;
// 3. Save audio response to database
// Determine status based on evaluation
let status = "ai_evaluated";
// Get attempt number (check if there's a previous response for this block)
let attempt_number: i32 = sqlx::query_scalar(
"SELECT COALESCE(MAX(attempt_number), 0) + 1 FROM audio_responses WHERE user_id = $1 AND lesson_id = $2 AND block_id = $3"
)
.bind(claims.sub)
.bind(lesson_id)
.bind(block_id)
.fetch_one(&pool)
.await
.unwrap_or(1);
// Store audio as base64 for now (can be moved to object storage later)
let audio_base64 = base64::engine::general_purpose::STANDARD.encode(&audio_data);
let _ = sqlx::query(
r#"INSERT INTO audio_responses
(organization_id, user_id, course_id, lesson_id, block_id, prompt, transcript, audio_data,
ai_score, ai_found_keywords, ai_feedback, ai_evaluated_at,
status, attempt_number, duration_seconds)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), $12, $13, $14)"#
)
.bind(org_ctx.id)
.bind(claims.sub)
.bind(course_id)
.bind(lesson_id)
.bind(block_id)
.bind(&prompt)
.bind(&transcript)
.bind(&audio_base64)
.bind(grading.score)
.bind(&grading.found_keywords)
.bind(&grading.feedback)
.bind(status)
.bind(attempt_number)
.bind(duration_seconds)
.execute(&pool)
.await;
Ok(Json(grading))
}
// ==================== AUDIO RESPONSE TEACHER ENDPOINTS ====================
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct AudioResponseListItem {
pub id: Uuid,
pub user_id: Uuid,
pub student_name: String,
pub student_email: String,
pub course_id: Uuid,
pub course_title: String,
pub lesson_id: Uuid,
pub lesson_title: String,
pub block_id: Uuid,
pub prompt: String,
pub transcript: Option<String>,
pub ai_score: Option<i32>,
pub ai_found_keywords: Option<Vec<String>>,
pub ai_feedback: Option<String>,
pub teacher_score: Option<i32>,
pub teacher_feedback: Option<String>,
pub status: String,
pub created_at: DateTime<Utc>,
pub attempt_number: i32,
}
#[derive(Debug, Deserialize)]
pub struct AudioResponseFilters {
pub course_id: Option<Uuid>,
pub lesson_id: Option<Uuid>,
pub status: Option<String>,
pub user_id: Option<Uuid>,
}
/// Get all audio responses for teachers
/// Filters: course_id, lesson_id, status (pending, ai_evaluated, teacher_evaluated, both_evaluated), user_id
pub async fn get_audio_responses(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Query(filters): Query<AudioResponseFilters>,
) -> Result<Json<Vec<AudioResponseListItem>>, StatusCode> {
// Only instructors and admins can access
if claims.role != "admin" && claims.role != "instructor" {
return Err(StatusCode::FORBIDDEN);
}
// Use static query with optional filters
let responses = sqlx::query_as::<_, AudioResponseListItem>(
r#"
SELECT
ar.id,
ar.user_id,
u.full_name as student_name,
u.email as student_email,
ar.course_id,
c.title as course_title,
ar.lesson_id,
l.title as lesson_title,
ar.block_id,
ar.prompt,
ar.transcript,
ar.ai_score,
ar.ai_found_keywords,
ar.ai_feedback,
ar.teacher_score,
ar.teacher_feedback,
ar.status::text,
ar.created_at,
ar.attempt_number
FROM audio_responses ar
JOIN users u ON ar.user_id = u.id
JOIN courses c ON ar.course_id = c.id
JOIN lessons l ON ar.lesson_id = l.id
WHERE ar.organization_id = $1
AND ($2::uuid IS NULL OR ar.course_id = $2)
AND ($3::uuid IS NULL OR ar.lesson_id = $3)
AND ($4::text IS NULL OR ar.status::text = $4)
AND ($5::uuid IS NULL OR ar.user_id = $5)
ORDER BY ar.created_at DESC
"#
)
.bind(org_ctx.id)
.bind(filters.course_id)
.bind(filters.lesson_id)
.bind(filters.status)
.bind(filters.user_id)
.fetch_all(&pool)
.await
.map_err(|e| {
tracing::error!("Error fetching audio responses: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(responses))
}
/// Get single audio response with full details including audio data
pub async fn get_audio_response_detail(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(response_id): Path<Uuid>,
) -> Result<Json<AudioResponseListItem>, StatusCode> {
// Only instructors and admins can access
if claims.role != "admin" && claims.role != "instructor" {
return Err(StatusCode::FORBIDDEN);
}
let response = sqlx::query_as::<_, AudioResponseListItem>(
r#"
SELECT
ar.id,
ar.user_id,
u.full_name as student_name,
u.email as student_email,
ar.course_id,
c.title as course_title,
ar.lesson_id,
l.title as lesson_title,
ar.block_id,
ar.prompt,
ar.transcript,
ar.ai_score,
ar.ai_found_keywords,
ar.ai_feedback,
ar.teacher_score,
ar.teacher_feedback,
ar.status::text,
ar.created_at,
ar.attempt_number
FROM audio_responses ar
JOIN users u ON ar.user_id = u.id
JOIN courses c ON ar.course_id = c.id
JOIN lessons l ON ar.lesson_id = l.id
WHERE ar.id = $1 AND ar.organization_id = $2
"#
)
.bind(response_id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("Error fetching audio response: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
match response {
Some(r) => Ok(Json(r)),
None => Err(StatusCode::NOT_FOUND),
}
}
/// Get audio data as base64 for playback
pub async fn get_audio_response_audio(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Path(response_id): Path<Uuid>,
) -> Result<impl IntoResponse, StatusCode> {
// Only instructors, admins, and the owner can access
let audio_data: Option<Vec<u8>> = sqlx::query_scalar(
"SELECT audio_data FROM audio_responses WHERE id = $1 AND organization_id = $2"
)
.bind(response_id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("Error fetching audio data: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
match audio_data {
Some(data) => {
// Decode from base64
let audio_bytes = base64::engine::general_purpose::STANDARD.decode(&data)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(axum::response::Response::builder()
.header(axum::http::header::CONTENT_TYPE, "audio/webm")
.header(axum::http::header::CONTENT_DISPOSITION, "inline")
.body(axum::body::Body::from(audio_bytes))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.into_response())
}
None => Err(StatusCode::NOT_FOUND),
}
}
/// Teacher evaluates an audio response
pub async fn teacher_evaluate_audio(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(response_id): Path<Uuid>,
Json(payload): Json<common::models::UpdateAudioResponsePayload>,
) -> Result<Json<serde_json::Value>, StatusCode> {
// Only instructors and admins can evaluate
if claims.role != "admin" && claims.role != "instructor" {
return Err(StatusCode::FORBIDDEN);
}
// Validate score
if payload.teacher_score < 0 || payload.teacher_score > 100 {
return Err(StatusCode::BAD_REQUEST);
}
// Get current response to determine new status
let current_status: String = sqlx::query_scalar(
"SELECT status::text FROM audio_responses WHERE id = $1 AND organization_id = $2"
)
.bind(response_id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("Error fetching audio response: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.unwrap_or_else(|| "pending".to_string());
// Determine new status
let new_status = if current_status == "ai_evaluated" {
"both_evaluated"
} else {
"teacher_evaluated"
};
// Update the response
let updated = sqlx::query(
r#"
UPDATE audio_responses
SET
teacher_score = $1,
teacher_feedback = $2,
teacher_evaluated_at = NOW(),
teacher_evaluated_by = $3,
status = $4,
updated_at = NOW()
WHERE id = $5 AND organization_id = $6
RETURNING id
"#
)
.bind(payload.teacher_score)
.bind(&payload.teacher_feedback)
.bind(claims.sub)
.bind(new_status)
.bind(response_id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("Error updating audio response: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
match updated {
Some(_) => Ok(Json(json!({
"success": true,
"message": "Evaluación guardada exitosamente"
}))),
None => Err(StatusCode::NOT_FOUND),
}
}
/// Get audio response statistics for a course
pub async fn get_audio_response_stats(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<common::models::AudioResponseStats>, StatusCode> {
// Only instructors and admins can access
if claims.role != "admin" && claims.role != "instructor" {
return Err(StatusCode::FORBIDDEN);
}
let stats = sqlx::query_as::<_, common::models::AudioResponseStats>(
r#"
SELECT
organization_id,
course_id,
lesson_id,
COUNT(*) as total_responses,
COUNT(*) FILTER (WHERE ai_score IS NOT NULL) as ai_evaluated,
COUNT(*) FILTER (WHERE teacher_score IS NOT NULL) as teacher_evaluated,
COUNT(*) FILTER (WHERE status = 'both_evaluated') as fully_evaluated,
COUNT(*) FILTER (WHERE status = 'pending') as pending,
AVG(ai_score) FILTER (WHERE ai_score IS NOT NULL) as avg_ai_score,
AVG(teacher_score) FILTER (WHERE teacher_score IS NOT NULL) as avg_teacher_score
FROM audio_responses
WHERE course_id = $1 AND organization_id = $2
GROUP BY organization_id, course_id, lesson_id
"#
)
.bind(course_id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("Error fetching audio response stats: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
match stats {
Some(s) => Ok(Json(s)),
None => Ok(Json(common::models::AudioResponseStats {
organization_id: org_ctx.id,
course_id,
lesson_id: Uuid::nil(),
total_responses: 0,
ai_evaluated: 0,
teacher_evaluated: 0,
fully_evaluated: 0,
pending: 0,
avg_ai_score: None,
avg_teacher_score: None,
})),
}
}
#[derive(Deserialize)]
pub struct ChatPayload {
pub message: String,
+47 -10
View File
@@ -66,17 +66,48 @@ async fn main() {
});
// CORS configuration - Allow multiple origins for development and production
// Using a predicate closure to support wildcard subdomains for norteamericano.cl
use tower_http::cors::AllowOrigin;
let cors = CorsLayer::new()
.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_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
let origin_str = origin.to_str().unwrap_or("");
// Development origins
let allowed_origins = [
"http://localhost:3000",
"http://localhost:3003",
"http://127.0.0.1:3000",
"http://127.0.0.1:3003",
"http://192.168.0.254:3000",
"http://192.168.0.254:3003",
"http://192.168.0.254",
// Production - Norteamericano domains (HTTPS)
"https://studio.norteamericano.cl",
"https://learning.norteamericano.cl",
];
// Check exact matches
if allowed_origins.contains(&origin_str) {
return true;
}
// Check wildcard for subdomains: https://*.norteamericano.cl
if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") {
let subdomain = origin_str
.strip_prefix("https://")
.unwrap_or("")
.strip_suffix(".norteamericano.cl")
.unwrap_or("");
// Allow any subdomain (e.g., api., cdn., admin., etc.)
if !subdomain.is_empty() && !subdomain.contains('/') {
return true;
}
}
false
}))
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
.allow_headers([
header::CONTENT_TYPE,
@@ -158,6 +189,12 @@ async fn main() {
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
.route("/audio/evaluate-file", post(handlers::evaluate_audio_file))
// Audio Response Teacher Routes
.route("/audio-responses", get(handlers::get_audio_responses))
.route("/audio-responses/{id}", get(handlers::get_audio_response_detail))
.route("/audio-responses/{id}/audio", get(handlers::get_audio_response_audio))
.route("/audio-responses/{id}/evaluate", post(handlers::teacher_evaluate_audio))
.route("/courses/{id}/audio-responses/stats", get(handlers::get_audio_response_stats))
.route("/lessons/{id}/chat", post(handlers::chat_with_tutor))
.route("/lessons/{id}/chat-role-play", post(handlers::chat_role_play))
.route("/lessons/{id}/code-hint", post(handlers::get_code_hint))