feat: Introduce external API endpoints with API key authentication and re-enable Whisper transcription and Ollama summarization.

This commit is contained in:
2026-01-21 16:22:12 -03:00
parent 1c55cc4ae7
commit 00ae5ac16b
7 changed files with 238 additions and 101 deletions
@@ -0,0 +1,98 @@
use axum::{
Json,
extract::{Path, State},
http::{StatusCode, HeaderMap},
};
use common::models::Course;
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
async fn validate_api_key(headers: &HeaderMap, pool: &PgPool) -> Result<Uuid, StatusCode> {
let api_key = headers
.get("X-API-Key")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let org_id: Uuid = sqlx::query_scalar("SELECT id FROM organizations WHERE api_key = $1")
.bind(Uuid::parse_str(api_key).map_err(|_| StatusCode::UNAUTHORIZED)?)
.fetch_one(pool)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(org_id)
}
pub async fn create_course_external(
State(pool): State<PgPool>,
headers: HeaderMap,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Course>, StatusCode> {
let org_id = validate_api_key(&headers, &pool).await?;
// We reuse the internal logic but with the org_id from the API key
// We need to provide a mock claims for handlers::create_course or refactor it.
// Simplifying for now: direct DB call or calling handlers with constructed context.
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
let description = payload.get("description").and_then(|d| d.as_str());
let course = sqlx::query_as::<_, Course>(
"INSERT INTO courses (organization_id, title, description, instructor_id, pacing_mode)
VALUES ($1, $2, $3, '00000000-0000-0000-0000-000000000001', $4) RETURNING *"
)
.bind(org_id)
.bind(title)
.bind(description)
.bind(payload.get("pacing_mode").and_then(|p| p.as_str()).unwrap_or("self_paced"))
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("External course creation failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(course))
}
pub async fn get_course_external(
State(pool): State<PgPool>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let org_id = validate_api_key(&headers, &pool).await?;
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
.bind(id)
.bind(org_id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(Json(json!({ "course": course })))
}
pub async fn trigger_transcription_external(
State(pool): State<PgPool>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
let org_id = validate_api_key(&headers, &pool).await?;
// Verify lesson belongs to org
let _ = sqlx::query("SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(id)
.bind(org_id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// Queue transcription
sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::ACCEPTED)
}
+83 -10
View File
@@ -730,18 +730,91 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(),
.await
.map_err(|e| format!("File read failed ({}): {}", file_path, e))?;
// 4. Transcription service is currently disabled in favor of Llama 3
tracing::warn!("Transcription service is disabled for lesson {}. Using Whisper removal policy.", lesson_id);
// 4. Send to Whisper
let whisper_url = env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
let client = reqwest::Client::new();
sqlx::query(
"UPDATE lessons SET transcription_status = 'failed' WHERE id = $1",
)
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| format!("Failed to update status to failed: {}", e))?;
// We assume a standard Whisper API (like faster-whisper-server or openai-compatible)
let form = reqwest::multipart::Form::new()
.part("file", reqwest::multipart::Part::bytes(file_data).file_name(filename.to_string()))
.text("model", "whisper-1")
.text("response_format", "json");
return Err("Transcription service is currently disabled. All AI features now use Llama 3 directly.".to_string());
let response = client.post(format!("{}/v1/audio/transcriptions", whisper_url))
.multipart(form)
.send()
.await
.map_err(|e| format!("Whisper request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let err_body = response.text().await.unwrap_or_default();
return Err(format!("Whisper API error: {} - {}", status, err_body));
}
let transcription_result: serde_json::Value = response.json().await
.map_err(|e| format!("Failed to parse Whisper response: {}", e))?;
// 5. Update lesson with transcription
sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2")
.bind(&transcription_result)
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| format!("Failed to update lesson with transcription: {}", e))?;
// 6. Optional: Trigger Summarization using Ollama
let full_text = transcription_result["text"].as_str().unwrap_or("");
if !full_text.is_empty() {
if let Ok(summary) = generate_summary_with_ollama(full_text).await {
let _ = sqlx::query("UPDATE lessons SET summary = $1 WHERE id = $2")
.bind(summary)
.bind(lesson_id)
.execute(&pool)
.await;
}
}
Ok(())
}
async fn generate_summary_with_ollama(text: &str) -> Result<String, String> {
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(|_| "llama3.2:3b".to_string());
let client = reqwest::Client::new();
let prompt = format!(
"Resume el siguiente texto de forma concisa y estructurada en español:\n\n{}",
text
);
let response = client.post(format!("{}/v1/chat/completions", base_url))
.json(&serde_json::json!({
"model": model,
"messages": [
{
"role": "user",
"content": prompt
}
],
"temperature": 0.5
}))
.send()
.await
.map_err(|e| format!("Ollama summary request failed: {}", e))?;
if !response.status().is_success() {
return Err("Ollama summary API error".into());
}
let result: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
let summary = result["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.trim()
.to_string();
Ok(summary)
}
pub async fn get_lesson_vtt(
+7
View File
@@ -3,6 +3,7 @@ pub mod exporter;
mod handlers;
mod handlers_branding;
mod webhooks;
mod external_handlers;
use axum::{
Router,
@@ -172,8 +173,14 @@ async fn main() {
common::middleware::org_extractor_middleware,
));
let api_routes = Router::new()
.route("/v1/courses", post(external_handlers::create_course_external))
.route("/v1/courses/{id}", get(external_handlers::get_course_external))
.route("/v1/lessons/{id}/transcribe", post(external_handlers::trigger_transcription_external));
// Rutas públicas que no requieren autenticación
let public_routes = Router::new()
.nest("/api/external", api_routes)
.route("/auth/register", post(handlers::register))
.route("/auth/login", post(handlers::login))
.route("/auth/sso/login/{org_id}", get(handlers::sso_login_init))