From 686eb377f027952a10fcf110b7dd94c2f1290ff2 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Thu, 19 Mar 2026 11:21:59 -0300 Subject: [PATCH] feat: activate AI usage --- ...319000000_ai_usage_add_prompt_response.sql | 52 +++++++++++++ services/cms-service/src/handlers.rs | 78 ++++++++++++++++++- .../cms-service/src/handlers_question_bank.rs | 73 ++++++++--------- web/studio/src/app/test-templates/page.tsx | 40 ++++------ 4 files changed, 182 insertions(+), 61 deletions(-) create mode 100644 services/cms-service/migrations/20260319000000_ai_usage_add_prompt_response.sql diff --git a/services/cms-service/migrations/20260319000000_ai_usage_add_prompt_response.sql b/services/cms-service/migrations/20260319000000_ai_usage_add_prompt_response.sql new file mode 100644 index 0000000..69328b8 --- /dev/null +++ b/services/cms-service/migrations/20260319000000_ai_usage_add_prompt_response.sql @@ -0,0 +1,52 @@ +-- AI Usage Logs: Add prompt and response fields for detailed tracking +-- This allows storing the actual prompts and responses for debugging and analytics + +-- Add new columns to ai_usage_logs +ALTER TABLE ai_usage_logs + ADD COLUMN IF NOT EXISTS prompt TEXT, + ADD COLUMN IF NOT EXISTS response TEXT; + +-- Update log_ai_usage function to accept prompt and response +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; + +-- Add indexes for text search (optional, can be enabled if needed for analytics) +-- CREATE INDEX idx_ai_usage_prompt ON ai_usage_logs USING gin (to_tsvector('spanish', prompt)); +-- CREATE INDEX idx_ai_usage_response ON ai_usage_logs USING gin (to_tsvector('spanish', response)); + +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'; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 0ee718f..cb99101 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -1135,6 +1135,29 @@ pub async fn summarize_lesson( .unwrap_or("") .trim(); + // Calculate and log token usage + let system_prompt = "You are an expert English Teacher. Summarize the following lesson content."; + let input_tokens = count_tokens(system_prompt) + count_tokens(&content_text); + let output_tokens = count_tokens(summary); + 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/summarize") + .bind(&model) + .bind("summary") + .bind(&json!({ + "lesson_id": id, + })) + .bind(&format!("{} - {}", system_prompt, content_text)) // prompt + .bind(summary) // response + .execute(&pool) + .await; + // 3. Update lesson let updated_lesson = sqlx::query_as::<_, Lesson>("UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *") @@ -1274,8 +1297,8 @@ pub async fn generate_quiz( let output_tokens = count_tokens(&response_json.to_string()); let total_tokens = input_tokens + output_tokens; - // Log AI usage - let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9)") + // Log AI usage with prompt and response + 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) @@ -1288,6 +1311,8 @@ pub async fn generate_quiz( "lesson_id": id, "quiz_type": quiz_req.quiz_type, })) + .bind(&system_prompt) // prompt + .bind(&response_json.to_string()) // response .execute(&pool) .await; @@ -1804,7 +1829,7 @@ pub async fn generate_mermaid_diagram( let transcription_str = lesson.transcription.as_ref().and_then(|v| v.as_str()); let summary_str = lesson.summary.as_deref(); let lesson_context = transcription_str.or(summary_str).unwrap_or("Conceptos generales de la lección."); - let user_hint = payload.prompt_hint.unwrap_or_else(|| "Extrae los conceptos y flujos principales de la lección.".to_string()); + let user_hint = payload.prompt_hint.clone().unwrap_or_else(|| "Extrae los conceptos y flujos principales de la lección.".to_string()); let system_prompt = format!( "Eres un experto arquitecto de información y especialista en diagramación usando Mermaid.js.\n\ @@ -1861,6 +1886,29 @@ pub async fn generate_mermaid_diagram( .strip_prefix("```\n").unwrap_or(ai_response) .strip_suffix("```").unwrap_or(ai_response).trim(); + // Calculate and log token usage + let input_tokens = count_tokens(&system_prompt) + count_tokens("Genera el código Mermaid directamente."); + let output_tokens = count_tokens(cleaned_response); + 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-mermaid") + .bind(&model) + .bind("diagram-generation") + .bind(&json!({ + "lesson_id": lesson_id, + "hint": payload.prompt_hint, + })) + .bind(&system_prompt) // prompt + .bind(cleaned_response) // response + .execute(&pool) + .await; + Ok(Json(serde_json::json!({ "mermaid_code": cleaned_response, }))) @@ -2116,6 +2164,30 @@ pub async fn generate_hotspots( } }; + // 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 + let output_tokens = count_tokens(&hotspots.to_string()); + 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-hotspots") + .bind(&model) + .bind("hotspots-generation") + .bind(&json!({ + "lesson_id": lesson_id, + "image_url": payload.image_url, + })) + .bind(&full_prompt) // prompt + .bind(&hotspots.to_string()) // response + .execute(&pool) + .await; + Ok(Json(hotspots)) } diff --git a/services/cms-service/src/handlers_question_bank.rs b/services/cms-service/src/handlers_question_bank.rs index 811dcd5..a3a49d8 100644 --- a/services/cms-service/src/handlers_question_bank.rs +++ b/services/cms-service/src/handlers_question_bank.rs @@ -135,9 +135,9 @@ fn calculate_course_type(plan_name: &str) -> String { } } -fn calculate_course_type_from_duration(duracion: Option) -> String { +fn calculate_course_type_from_duration(duracion: Option) -> String { match duracion { - Some(d) if d >= 70 => "intensive".to_string(), // 80h or more = intensive + Some(d) if d as i64 >= 70 => "intensive".to_string(), // 80h or more = intensive _ => "regular".to_string(), // 40h or less = regular } } @@ -396,7 +396,12 @@ pub async fn import_from_mysql( ) .fetch_all(&mysql_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch plans: {}", e)))?; + .map_err(|e| { + let error_msg = format!("Failed to fetch: {}", e); + tracing::error!("MySQL Error: {}", error_msg); + tracing::error!("Check column names in your MySQL database (plandeestudios, curso tables)"); + (StatusCode::INTERNAL_SERVER_ERROR, error_msg) + })?; tracing::info!("Fetched {} study plans from MySQL", mysql_plans.len()); @@ -408,7 +413,7 @@ pub async fn import_from_mysql( c.NivelCurso AS nivel_curso, pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan, - CAST(c.Duracion AS SIGNED INTEGER) AS duracion + c.Duracion AS duracion FROM curso c JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios WHERE c.Activo = 1 @@ -757,7 +762,7 @@ pub async fn list_mysql_courses( c.NivelCurso AS nivel_curso, pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan, - CAST(c.Duracion AS SIGNED INTEGER) AS duracion + c.Duracion AS duracion FROM curso c JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios WHERE c.Activo = 1 @@ -783,8 +788,8 @@ pub async fn get_mysql_plans( let plans: Vec = sqlx::query_as( r#" SELECT - mysql_id as "idPlanDeEstudios", - name as "NombrePlan" + mysql_id as id_plan_de_estudios, + name as nombre_plan FROM mysql_study_plans WHERE organization_id = $1 AND is_active = true ORDER BY name @@ -808,12 +813,12 @@ pub async fn get_mysql_courses_by_plan( let courses: Vec = sqlx::query_as( r#" SELECT - c.mysql_id as "idCursos", - c.name as "NombreCurso", - c.level as "NivelCurso", - sp.mysql_id as "idPlanDeEstudios", - sp.name as "NombrePlan", - c.duracion as "Duracion" + c.mysql_id as id_cursos, + c.name as nombre_curso, + c.level as nivel_curso, + sp.mysql_id as id_plan_de_estudios, + sp.name as nombre_plan, + c.duracion as duracion FROM mysql_courses c JOIN mysql_study_plans sp ON c.study_plan_id = sp.id WHERE c.organization_id = $1 @@ -838,34 +843,18 @@ pub struct MySqlCoursesFilters { #[derive(Debug, Clone, sqlx::FromRow, Serialize)] pub struct MySqlPlanInfo { - #[sqlx(rename = "idPlanDeEstudios")] - #[serde(rename = "idPlanDeEstudios")] pub id_plan_de_estudios: i32, - #[sqlx(rename = "NombrePlan")] - #[serde(rename = "NombrePlan")] pub nombre_plan: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct MySqlCourseInfo { - #[sqlx(rename = "idCursos")] - #[serde(rename = "idCursos")] pub id_cursos: i32, - #[sqlx(rename = "NombreCurso")] - #[serde(rename = "NombreCurso")] pub nombre_curso: String, - #[sqlx(rename = "NivelCurso")] - #[serde(rename = "NivelCurso", skip_serializing_if = "Option::is_none")] pub nivel_curso: Option, - #[sqlx(rename = "idPlanDeEstudios")] - #[serde(rename = "idPlanDeEstudios")] pub id_plan_de_estudios: i32, - #[sqlx(rename = "NombrePlan")] - #[serde(rename = "NombrePlan")] pub nombre_plan: String, - #[sqlx(rename = "Duracion")] - #[serde(rename = "Duracion", skip_serializing_if = "Option::is_none")] - pub duracion: Option, // Duration in hours (40=regular, 80=intensive) + pub duracion: Option, // Duration in hours (40=regular, 80=intensive) - MySQL float type } #[derive(Debug, Serialize, Deserialize)] @@ -878,10 +867,19 @@ pub async fn import_all_from_mysql( Org(org_ctx): Org, claims: Claims, State(pool): State, - Json(payload): Json, + body: String, ) -> Result, (StatusCode, String)> { use serde_json::json; + // Parse optional JSON body + let import_metadata_only = if body.trim().is_empty() { + false + } else { + serde_json::from_str::(&body) + .map(|p| p.import_metadata_only.unwrap_or(false)) + .unwrap_or(false) + }; + // Connect to MySQL let mysql_url = std::env::var("MYSQL_DATABASE_URL") .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "MYSQL_DATABASE_URL not configured".to_string()))?; @@ -903,7 +901,12 @@ pub async fn import_all_from_mysql( ) .fetch_all(&mysql_pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch plans: {}", e)))?; + .map_err(|e| { + let error_msg = format!("Failed to fetch: {}", e); + tracing::error!("MySQL Error: {}", error_msg); + tracing::error!("Check column names in your MySQL database (plandeestudios, curso tables)"); + (StatusCode::INTERNAL_SERVER_ERROR, error_msg) + })?; tracing::info!("Fetched {} study plans from MySQL", mysql_plans.len()); @@ -915,7 +918,7 @@ pub async fn import_all_from_mysql( c.NivelCurso AS nivel_curso, pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan, - CAST(c.Duracion AS SIGNED INTEGER) AS duracion + c.Duracion AS duracion FROM curso c JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios WHERE c.Activo = 1 @@ -936,7 +939,7 @@ pub async fn import_all_from_mysql( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save courses/plans: {}", e)))?; // If only metadata import is requested, return early - if payload.import_metadata_only.unwrap_or(false) { + if import_metadata_only { return Ok(Json(json!({ "success": true, "message": "Metadatos importados exitosamente", @@ -1305,7 +1308,7 @@ pub async fn import_course_from_mysql( c.NivelCurso AS nivel_curso, pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan, - CAST(c.Duracion AS SIGNED INTEGER) AS duracion + c.Duracion AS duracion FROM curso c JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios WHERE c.idCursos = ? AND c.Activo = 1 AND pe.Activo = 1 diff --git a/web/studio/src/app/test-templates/page.tsx b/web/studio/src/app/test-templates/page.tsx index bffd6ad..86d2104 100644 --- a/web/studio/src/app/test-templates/page.tsx +++ b/web/studio/src/app/test-templates/page.tsx @@ -1,34 +1,28 @@ 'use client'; -import React, { useState } from 'react'; +import React from 'react'; import PageLayout from '@/components/PageLayout'; -import { TestTemplateManager, TestTemplateForm } from '@/components/TestTemplates'; -//import { Plus } from 'lucide-react'; +import { AlertTriangle } from 'lucide-react'; export default function TestTemplatesPage() { - const [showCreateForm, setShowCreateForm] = useState(false); - const [refreshKey, setRefreshKey] = useState(0); - - const handleSuccess = () => { - setShowCreateForm(false); - setRefreshKey(prev => prev + 1); - }; - return ( -
- setShowCreateForm(true)} - onCreateTemplate={() => setShowCreateForm(true)} - /> +
+
+
+ +

+ Funcionalidad Temporalmente Desactivada +

+
+

+ Las plantillas de pruebas están temporalmente desactivadas mientras se realizan mejoras en el sistema. +

+

+ Esta funcionalidad estará disponible próximamente. +

+
- - {showCreateForm && ( - setShowCreateForm(false)} - /> - )} ); }