feat: token count implement

This commit is contained in:
2026-03-17 12:07:56 -03:00
parent 41279585f6
commit be699ad6ab
44 changed files with 9032 additions and 167 deletions
@@ -0,0 +1,174 @@
-- Question Bank: Centralized repository for reusable questions
-- Supports multiple question types, audio generation, and multi-tenant organization
-- Question types supported by the platform
CREATE TYPE question_bank_type AS ENUM (
'multiple-choice', -- Multiple choice with single/multiple correct answers
'true-false', -- True/False questions
'short-answer', -- Short text answer
'essay', -- Long form text answer
'matching', -- Match pairs
'ordering', -- Order items correctly
'fill-in-the-blanks', -- Fill in missing words
'audio-response', -- Record audio answer
'hotspot', -- Click on image area
'code-lab' -- Code exercise
);
-- Question Bank table
CREATE TABLE question_bank (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
-- Question content
question_text TEXT NOT NULL,
question_type question_bank_type NOT NULL,
-- Answers and options (structure depends on question_type)
options JSONB, -- Array of options for multiple-choice/true-false
correct_answer JSONB, -- Correct answer(s) - format varies by type
explanation TEXT, -- Explanation shown after answering (AI generated or manual)
-- Audio support (Bark TTS)
audio_url TEXT, -- URL to generated audio file
audio_text TEXT, -- Text used for audio generation (may differ from question_text)
audio_status VARCHAR(50) DEFAULT 'pending', -- pending, generating, ready, failed
audio_metadata JSONB, -- Bark generation parameters
-- Media support
media_url TEXT, -- Image/video URL for hotspot or context questions
media_type VARCHAR(50), -- image, video
-- Metadata
points INTEGER NOT NULL DEFAULT 1,
difficulty VARCHAR(20) DEFAULT 'medium', -- easy, medium, hard
tags TEXT[], -- For searching and categorization
skill_assessed VARCHAR(20), -- reading, listening, speaking, writing
-- Source tracking
source VARCHAR(50) DEFAULT 'manual', -- manual, ai-generated, imported-mysql, imported-csv
source_metadata JSONB, -- Original source data (e.g., MySQL question ID)
imported_mysql_id INTEGER, -- Original MySQL question ID to prevent re-import
imported_mysql_course_id INTEGER, -- Original MySQL course ID
-- Usage tracking
usage_count INTEGER DEFAULT 0, -- How many times used in templates/courses
last_used_at TIMESTAMPTZ,
-- Status
is_active BOOLEAN DEFAULT true,
is_archived BOOLEAN DEFAULT false,
-- Audit
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT question_bank_points_positive CHECK (points > 0)
);
-- Indexes for performance
CREATE INDEX idx_question_bank_org ON question_bank(organization_id);
CREATE INDEX idx_question_bank_type ON question_bank(question_type);
CREATE INDEX idx_question_bank_difficulty ON question_bank(difficulty);
CREATE INDEX idx_question_bank_tags ON question_bank USING GIN(tags);
CREATE INDEX idx_question_bank_source ON question_bank(source);
CREATE INDEX idx_question_bank_active ON question_bank(is_active) WHERE is_active = true;
CREATE INDEX idx_question_bank_search ON question_bank USING GIN(to_tsvector('english', question_text));
-- Question Bank Categories (optional organization)
CREATE TABLE question_bank_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
parent_id UUID REFERENCES question_bank_categories(id) ON DELETE CASCADE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(organization_id, name)
);
-- Link questions to categories
CREATE TABLE question_bank_question_categories (
question_id UUID NOT NULL REFERENCES question_bank(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES question_bank_categories(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (question_id, category_id)
);
-- Question Bank Usage History (track where questions are used)
CREATE TABLE question_bank_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
question_id UUID NOT NULL REFERENCES question_bank(id) ON DELETE CASCADE,
used_in_type VARCHAR(50) NOT NULL, -- template, lesson, quiz
used_in_id UUID NOT NULL, -- ID of the template/lesson/quiz
organization_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX idx_question_bank_usage_question ON question_bank_usage(question_id);
CREATE INDEX idx_question_bank_usage_used_in ON question_bank_usage(used_in_type, used_in_id);
-- Trigger to update updated_at
CREATE TRIGGER trg_question_bank_updated_at
BEFORE UPDATE ON question_bank
FOR EACH ROW
EXECUTE FUNCTION update_test_templates_updated_at(); -- Reuse existing function
-- Function to increment usage count
CREATE OR REPLACE FUNCTION increment_question_usage(p_question_id UUID, p_used_in_type VARCHAR, p_used_in_id UUID, p_org_id UUID, p_user_id UUID)
RETURNS VOID AS $$
BEGIN
-- Update usage count
UPDATE question_bank
SET usage_count = usage_count + 1,
last_used_at = NOW()
WHERE id = p_question_id;
-- Log usage
INSERT INTO question_bank_usage (question_id, used_in_type, used_in_id, organization_id, created_by)
VALUES (p_question_id, p_used_in_type, p_used_in_id, p_org_id, p_user_id);
END;
$$ LANGUAGE plpgsql;
-- Function to import question from MySQL
CREATE OR REPLACE FUNCTION import_question_from_mysql(
p_org_id UUID,
p_user_id UUID,
p_question_text TEXT,
p_question_type question_bank_type,
p_options JSONB,
p_correct_answer JSONB,
p_source_metadata JSONB
)
RETURNS UUID AS $$
DECLARE
v_question_id UUID;
BEGIN
INSERT INTO question_bank (
organization_id, created_by, question_text, question_type,
options, correct_answer, source, source_metadata,
audio_status, is_active
)
VALUES (
p_org_id, p_user_id, p_question_text, p_question_type,
p_options, p_correct_answer, 'imported-mysql', p_source_metadata,
'pending', true
)
RETURNING id INTO v_question_id;
RETURN v_question_id;
END;
$$ LANGUAGE plpgsql;
-- Comments
COMMENT ON TABLE question_bank IS 'Centralized repository for reusable questions across the organization';
COMMENT ON COLUMN question_bank.question_type IS 'Type of question: multiple-choice, true-false, short-answer, essay, matching, ordering, fill-in-the-blanks, audio-response, hotspot, code-lab';
COMMENT ON COLUMN question_bank.audio_url IS 'URL to Bark-generated audio file for text-to-speech';
COMMENT ON COLUMN question_bank.audio_status IS 'Status of audio generation: pending, generating, ready, failed';
COMMENT ON COLUMN question_bank.source IS 'Origin: manual (created by user), ai-generated, imported-mysql, imported-csv';
COMMENT ON COLUMN question_bank.source_metadata IS 'Original source data, e.g., {mysql_table: "bancopreguntas", idPregunta: 123, idCursos: 456}';
COMMENT ON TABLE question_bank_usage IS 'Tracks where each question is used (templates, lessons, quizzes)';
@@ -0,0 +1,86 @@
-- AI Usage Logs: Track token consumption per user for billing and limits
CREATE TABLE ai_usage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
-- Token counts
tokens_used INTEGER NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
-- Request metadata
endpoint VARCHAR(255), -- e.g., "/lessons/generate-quiz"
model VARCHAR(100), -- e.g., "llama3.2:3b", "gpt-4o"
request_type VARCHAR(50), -- "transcription", "quiz-generation", "chat", "summary"
request_metadata JSONB, -- Additional context about the request
-- Cost estimation (optional, can be calculated later)
estimated_cost_usd NUMERIC(10, 6),
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes for performance
CREATE INDEX idx_ai_usage_user ON ai_usage_logs(user_id);
CREATE INDEX idx_ai_usage_org ON ai_usage_logs(organization_id);
CREATE INDEX idx_ai_usage_created ON ai_usage_logs(created_at);
CREATE INDEX idx_ai_usage_endpoint ON ai_usage_logs(endpoint);
CREATE INDEX idx_ai_usage_type ON ai_usage_logs(request_type);
-- Function to log AI usage
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
)
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
)
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
)
RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql;
-- Daily aggregation view for reporting
CREATE VIEW ai_usage_daily AS
SELECT
DATE(created_at) as usage_date,
user_id,
organization_id,
request_type,
SUM(tokens_used) as total_tokens,
SUM(input_tokens) as total_input,
SUM(output_tokens) as total_output,
COUNT(*) as request_count,
SUM(estimated_cost_usd) as total_cost
FROM ai_usage_logs
GROUP BY DATE(created_at), user_id, organization_id, request_type;
-- Comments
COMMENT ON TABLE ai_usage_logs IS 'Tracks AI token usage per user for billing, limits, and analytics';
COMMENT ON COLUMN ai_usage_logs.tokens_used IS 'Total tokens (input + output)';
COMMENT ON COLUMN ai_usage_logs.input_tokens IS 'Tokens in the prompt/request';
COMMENT ON COLUMN ai_usage_logs.output_tokens IS 'Tokens in the AI response';
COMMENT ON COLUMN ai_usage_logs.estimated_cost_usd IS 'Estimated cost based on OpenAI-like pricing';
+55 -8
View File
@@ -54,6 +54,15 @@ fn get_ai_url(var_base: &str, default: &str) -> String {
}
}
/// Simple token counter (approximate: 1 token ≈ 4 characters in English)
fn count_tokens(text: &str) -> i32 {
// More accurate for English: split by whitespace and count words * 1.3
// For Spanish/other languages, character-based is more reliable
let char_count = text.len();
// OpenAI estimate: ~4 chars per token
((char_count as f64) / 4.0).ceil() as i32
}
pub async fn publish_course(
Org(org_ctx): Org,
claims: Claims,
@@ -883,13 +892,16 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(),
// 5. Bilingual translation with Ollama
let text = transcription_result["text"].as_str().unwrap_or("").to_string();
let detected_lang = transcription_result["language"].as_str().unwrap_or("es").to_string();
// Ensure the detected language text is stored in its own key
transcription_result[detected_lang.clone()] = serde_json::json!(text);
let target_lang = if detected_lang == "es" { "en" } else { "es" };
tracing::info!("Translating transcription from {} to {} using Ollama...", detected_lang, target_lang);
// Note: Token usage for transcription is logged in the caller context
// where we have access to user_id and org_id
if !text.is_empty() {
match translate_text(&text, target_lang).await {
Ok(translated) => {
@@ -1248,16 +1260,38 @@ pub async fn generate_quiz(
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
tracing::error!("Quiz API error: {}", err_body);
let response_status = response.status();
if !response_status.is_success() {
tracing::error!("Quiz API error: {}", response_status);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
let response_json: serde_json::Value = response.json().await.unwrap_or_default();
// Calculate token usage
let input_tokens = count_tokens(&system_prompt) + count_tokens(&content_text);
let output_tokens = count_tokens(&response_json.to_string());
let total_tokens = input_tokens + output_tokens;
let quiz_data: serde_json::Value = response
.json()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Log AI usage
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9)")
.bind(claims.sub)
.bind(org_ctx.id)
.bind(total_tokens)
.bind(input_tokens)
.bind(output_tokens)
.bind("/lessons/generate-quiz")
.bind(&model)
.bind("quiz-generation")
.bind(&json!({
"lesson_id": id,
"quiz_type": quiz_req.quiz_type,
}))
.execute(&pool)
.await;
let quiz_data: serde_json::Value = response_json;
let quiz_json_str = quiz_data["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("{}");
@@ -2575,6 +2609,19 @@ pub async fn get_organization(
Ok(Json(org))
}
/// GET /organization - Public endpoint (returns default organization)
pub async fn get_public_organization(
State(pool): State<PgPool>,
) -> Result<Json<Organization>, StatusCode> {
// Get the first/default organization
let org = sqlx::query_as::<_, Organization>("SELECT * FROM organizations ORDER BY created_at LIMIT 1")
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(Json(org))
}
pub async fn get_me(
claims: Claims,
State(pool): State<PgPool>,
+136
View File
@@ -0,0 +1,136 @@
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
};
use common::{auth::Claims, middleware::Org};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
// ==================== Token Usage Tracking ====================
/// GET /api/admin/token-usage - Get token usage statistics for all users
pub async fn get_token_usage(
_org_ctx: Org,
_claims: Claims,
State(pool): State<PgPool>,
) -> Result<Json<TokenUsageResponse>, (StatusCode, String)> {
// Get user token usage from database
let usage: Vec<TokenUsageRecord> = sqlx::query_as(
r#"
SELECT
u.id as user_id,
u.email,
u.full_name,
u.role,
COALESCE(SUM(au.tokens_used), 0) as total_tokens,
COALESCE(SUM(au.input_tokens), 0) as input_tokens,
COALESCE(SUM(au.output_tokens), 0) as output_tokens,
COUNT(au.id) as ai_requests,
MAX(au.created_at) as last_used
FROM users u
LEFT JOIN ai_usage_logs au ON u.id = au.user_id
GROUP BY u.id, u.email, u.full_name, u.role
ORDER BY total_tokens DESC
"#
)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch usage: {}", e)))?;
// Calculate stats
let total_tokens: i64 = usage.iter().map(|u| u.total_tokens).sum();
let total_input: i64 = usage.iter().map(|u| u.input_tokens).sum();
let total_output: i64 = usage.iter().map(|u| u.output_tokens).sum();
let total_requests: i64 = usage.iter().map(|u| u.ai_requests).sum();
let top_user_tokens = usage.first().map(|u| u.total_tokens).unwrap_or(0);
let avg_tokens = if !usage.is_empty() { total_tokens / usage.len() as i64 } else { 0 };
// Estimate cost (using approximate OpenAI pricing: $0.001/1K input, $0.003/1K output)
let estimated_cost = (total_input as f64 * 0.000001) + (total_output as f64 * 0.000003);
let stats = TokenUsageStats {
total_tokens,
total_input,
total_output,
total_requests,
total_cost_usd: estimated_cost,
top_user_tokens,
avg_tokens_per_user: avg_tokens,
};
// Convert to response format with USD estimation per user
let usage_with_cost: Vec<TokenUsage> = usage
.into_iter()
.map(|u| {
let user_cost = (u.input_tokens as f64 * 0.000001) + (u.output_tokens as f64 * 0.000003);
TokenUsage {
user_id: u.user_id.to_string(),
email: u.email,
full_name: u.full_name,
role: u.role,
total_tokens: u.total_tokens,
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
ai_requests: u.ai_requests,
last_used: u.last_used.to_rfc3339(),
estimated_cost_usd: user_cost,
}
})
.collect();
Ok(Json(TokenUsageResponse {
usage: usage_with_cost,
stats,
}))
}
#[derive(Debug, Deserialize)]
pub struct TokenUsageFilters {
pub role: Option<String>,
pub min_tokens: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct TokenUsage {
pub user_id: String,
pub email: String,
pub full_name: String,
pub role: String,
pub total_tokens: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub ai_requests: i64,
pub last_used: String,
pub estimated_cost_usd: f64,
}
#[derive(Debug, sqlx::FromRow)]
struct TokenUsageRecord {
user_id: uuid::Uuid,
email: String,
full_name: String,
role: String,
total_tokens: i64,
input_tokens: i64,
output_tokens: i64,
ai_requests: i64,
last_used: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
pub struct TokenUsageStats {
pub total_tokens: i64,
pub total_input: i64,
pub total_output: i64,
pub total_requests: i64,
pub total_cost_usd: f64,
pub top_user_tokens: i64,
pub avg_tokens_per_user: i64,
}
#[derive(Debug, Serialize)]
pub struct TokenUsageResponse {
pub usage: Vec<TokenUsage>,
pub stats: TokenUsageStats,
}
@@ -0,0 +1,818 @@
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use common::models::{
CreateQuestionBankPayload, ImportQuestionFromMySQLPayload, QuestionBank, QuestionBankFilters,
QuestionBankType, UpdateQuestionBankPayload,
};
use common::{auth::Claims, middleware::Org};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
// ==================== Create ====================
/// POST /api/question-bank - Create a new question in the bank
pub async fn create_question(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<CreateQuestionBankPayload>,
) -> Result<Json<QuestionBank>, (StatusCode, String)> {
let question: QuestionBank = sqlx::query_as(
r#"
INSERT INTO question_bank (
organization_id, created_by, question_text, question_type,
options, correct_answer, explanation, points, difficulty,
tags, skill_assessed, media_url, media_type, audio_status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 'pending')
RETURNING *
"#
)
.bind(org_ctx.id)
.bind(claims.sub)
.bind(&payload.question_text)
.bind(&payload.question_type)
.bind(&payload.options)
.bind(&payload.correct_answer)
.bind(&payload.explanation)
.bind(payload.points.unwrap_or(1))
.bind(payload.difficulty.as_deref().unwrap_or("medium"))
.bind(payload.tags.as_deref())
.bind(payload.skill_assessed.as_deref())
.bind(payload.media_url.as_deref())
.bind(payload.media_type.as_deref())
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// If audio generation requested, trigger it asynchronously
if payload.generate_audio.unwrap_or(false) {
tokio::spawn(async move {
let _ = generate_audio_for_question(question.id, pool.clone()).await;
});
}
Ok(Json(question))
}
// ==================== List ====================
/// GET /api/question-bank - List questions with filters
pub async fn list_questions(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Query(filters): Query<QuestionBankFilters>,
) -> Result<Json<Vec<QuestionBank>>, (StatusCode, String)> {
let questions = if filters.question_type.is_none()
&& filters.difficulty.is_none()
&& filters.source.is_none()
&& filters.search.is_none()
&& filters.has_audio.is_none()
{
// No filters - simple query
sqlx::query_as::<_, QuestionBank>(
"SELECT * FROM question_bank WHERE organization_id = $1 AND is_archived = false ORDER BY created_at DESC"
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
} else if filters.question_type.is_some() {
sqlx::query_as::<_, QuestionBank>(
"SELECT * FROM question_bank WHERE organization_id = $1 AND is_archived = false AND question_type = $2 ORDER BY created_at DESC"
)
.bind(org_ctx.id)
.bind(filters.question_type.unwrap())
.fetch_all(&pool)
.await
} else if filters.difficulty.is_some() {
sqlx::query_as::<_, QuestionBank>(
"SELECT * FROM question_bank WHERE organization_id = $1 AND is_archived = false AND difficulty = $2 ORDER BY created_at DESC"
)
.bind(org_ctx.id)
.bind(filters.difficulty.as_ref().unwrap())
.fetch_all(&pool)
.await
} else if filters.source.is_some() {
sqlx::query_as::<_, QuestionBank>(
"SELECT * FROM question_bank WHERE organization_id = $1 AND is_archived = false AND source = $2 ORDER BY created_at DESC"
)
.bind(org_ctx.id)
.bind(filters.source.as_ref().unwrap())
.fetch_all(&pool)
.await
} else if filters.has_audio == Some(true) {
sqlx::query_as::<_, QuestionBank>(
"SELECT * FROM question_bank WHERE organization_id = $1 AND is_archived = false AND audio_status = 'ready' ORDER BY created_at DESC"
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
} else {
// Default fallback
sqlx::query_as::<_, QuestionBank>(
"SELECT * FROM question_bank WHERE organization_id = $1 AND is_archived = false ORDER BY created_at DESC"
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
}
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(questions))
}
// ==================== Get ====================
/// GET /api/question-bank/{id} - Get a single question
pub async fn get_question(
Org(org_ctx): Org,
Path(id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<QuestionBank>, (StatusCode, String)> {
let question: QuestionBank = sqlx::query_as(
r#"
SELECT * FROM question_bank
WHERE id = $1 AND organization_id = $2 AND is_archived = false
"#
)
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Question not found".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
Ok(Json(question))
}
// ==================== Update ====================
/// PUT /api/question-bank/{id} - Update a question
pub async fn update_question(
Org(org_ctx): Org,
Path(id): Path<Uuid>,
State(pool): State<PgPool>,
Json(payload): Json<UpdateQuestionBankPayload>,
) -> Result<Json<QuestionBank>, (StatusCode, String)> {
let question: QuestionBank = sqlx::query_as(
r#"
UPDATE question_bank
SET
question_text = COALESCE($3, question_text),
question_type = COALESCE($4, question_type),
options = COALESCE($5, options),
correct_answer = COALESCE($6, correct_answer),
explanation = COALESCE($7, explanation),
points = COALESCE($8, points),
difficulty = COALESCE($9, difficulty),
tags = COALESCE($10, tags),
is_active = COALESCE($11, is_active),
is_archived = COALESCE($12, is_archived),
updated_at = NOW()
WHERE id = $1 AND organization_id = $2
RETURNING *
"#
)
.bind(id)
.bind(org_ctx.id)
.bind(payload.question_text)
.bind(payload.question_type.map(|t| t.to_string()))
.bind(&payload.options)
.bind(&payload.correct_answer)
.bind(&payload.explanation)
.bind(payload.points)
.bind(payload.difficulty)
.bind(payload.tags.as_deref())
.bind(payload.is_active)
.bind(payload.is_archived)
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Question not found".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
Ok(Json(question))
}
// ==================== Delete ====================
/// DELETE /api/question-bank/{id} - Delete (archive) a question
pub async fn delete_question(
Org(org_ctx): Org,
Path(id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
let result = sqlx::query(
r#"
UPDATE question_bank
SET is_archived = true, updated_at = NOW()
WHERE id = $1 AND organization_id = $2
"#
)
.bind(id)
.bind(org_ctx.id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Question not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Import from MySQL ====================
/// POST /api/question-bank/import-mysql - Import questions from MySQL question bank
pub async fn import_from_mysql(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<ImportQuestionFromMySQLPayload>,
) -> Result<Json<Vec<QuestionBank>>, (StatusCode, String)> {
use serde_json::json;
// 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()))?;
let mysql_pool = sqlx::MySqlPool::connect(&mysql_url)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to MySQL: {}", e)))?;
// Fetch questions from MySQL
let mysql_questions: Vec<MySqlQuestion> = if payload.import_all.unwrap_or(false) {
sqlx::query_as(
r#"
SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo,
c.idCursos, c.NombreCurso, pe.idPlanDeEstudios, pe.Nombre as PlanNombre
FROM bancopreguntas bp
JOIN curso c ON bp.idCursos = c.idCursos
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
WHERE bp.activo = 1
LIMIT 200
"#
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
} else if let Some(course_id) = payload.mysql_course_id {
sqlx::query_as(
r#"
SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo,
c.idCursos, c.NombreCurso, pe.idPlanDeEstudios, pe.Nombre as PlanNombre
FROM bancopreguntas bp
JOIN curso c ON bp.idCursos = c.idCursos
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
WHERE bp.idCursos = ? AND bp.activo = 1
LIMIT 100
"#
)
.bind(course_id)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
} else if let Some(question_ids) = payload.question_ids {
// Fetch specific question IDs - use simple approach
let mut imported_questions: Vec<QuestionBank> = vec![];
for q_id in question_ids {
let mq: Option<MySqlQuestion> = sqlx::query_as(
r#"
SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo,
c.idCursos, c.NombreCurso, pe.idPlanDeEstudios, pe.Nombre as PlanNombre
FROM bancopreguntas bp
JOIN curso c ON bp.idCursos = c.idCursos
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
WHERE bp.idPregunta = ? AND bp.activo = 1
"#
)
.bind(q_id)
.fetch_optional(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch question {}: {}", q_id, e)))?;
if let Some(question) = mq {
// Map MySQL question type to platform question type
let question_type = map_mysql_question_type(question.id_tipo_pregunta);
let options = if question_type == QuestionBankType::MultipleChoice {
Some(json!(["Opción A", "Opción B", "Opción C", "Opción D"]))
} else if question_type == QuestionBankType::TrueFalse {
Some(json!(["Verdadero", "Falso"]))
} else {
None
};
let source_metadata = json!({
"mysql_table": "bancopreguntas",
"idPregunta": question.id_pregunta,
"idCursos": question.id_cursos,
"nombre_curso": question.nombre_curso,
"idPlanDeEstudios": question.id_plan_de_estudios,
"plan_nombre": question.plan_nombre,
"idTipoPregunta": question.id_tipo_pregunta,
"imported_at": chrono::Utc::now().to_rfc3339(),
});
let qb: QuestionBank = sqlx::query_as(
r#"
INSERT INTO question_bank (
organization_id, created_by, question_text, question_type,
options, correct_answer, source, source_metadata,
audio_status, is_active
)
VALUES ($1, $2, $3, $4, $5, $6, 'imported-mysql', $7, 'pending', true)
RETURNING *
"#
)
.bind(org_ctx.id)
.bind(claims.sub)
.bind(&question.descripcion)
.bind(&question_type)
.bind(&options)
.bind(&serde_json::Value::Null)
.bind(&source_metadata)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to import question {}: {}", question.id_pregunta, e)))?;
imported_questions.push(qb);
}
}
mysql_pool.close().await;
return Ok(Json(imported_questions));
} else {
return Err((StatusCode::BAD_REQUEST, "Must provide course_id, question_ids, or import_all".to_string()));
};
mysql_pool.close().await;
if mysql_questions.is_empty() {
return Err((StatusCode::NOT_FOUND, "No questions found in MySQL to import".to_string()));
}
// Import questions into PostgreSQL
let mut imported_questions: Vec<QuestionBank> = vec![];
let mut skipped_count = 0;
for mq in mysql_questions {
// Check if question already imported
let exists: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM question_bank WHERE imported_mysql_id = $1 AND organization_id = $2)"
)
.bind(mq.id_pregunta)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to check existing: {}", e)))?;
if exists.0 {
skipped_count += 1;
continue; // Skip already imported question
}
// Map MySQL question type to platform question type
let question_type = map_mysql_question_type(mq.id_tipo_pregunta);
// Create options for multiple choice (if applicable)
let options = if question_type == QuestionBankType::MultipleChoice {
Some(json!(["Opción A", "Opción B", "Opción C", "Opción D"]))
} else if question_type == QuestionBankType::TrueFalse {
Some(json!(["Verdadero", "Falso"]))
} else {
None
};
let source_metadata = json!({
"mysql_table": "bancopreguntas",
"idPregunta": mq.id_pregunta,
"idCursos": mq.id_cursos,
"nombre_curso": mq.nombre_curso,
"idPlanDeEstudios": mq.id_plan_de_estudios,
"plan_nombre": mq.plan_nombre,
"idTipoPregunta": mq.id_tipo_pregunta,
"imported_at": chrono::Utc::now().to_rfc3339(),
});
let question: QuestionBank = sqlx::query_as(
r#"
INSERT INTO question_bank (
organization_id, created_by, question_text, question_type,
options, correct_answer, source, source_metadata,
imported_mysql_id, imported_mysql_course_id,
audio_status, is_active
)
VALUES ($1, $2, $3, $4, $5, $6, 'imported-mysql', $7, $8, $9, 'pending', true)
RETURNING *
"#
)
.bind(org_ctx.id)
.bind(claims.sub)
.bind(&mq.descripcion)
.bind(&question_type)
.bind(&options)
.bind(&serde_json::Value::Null) // Correct answer to be filled by user
.bind(&source_metadata)
.bind(mq.id_pregunta)
.bind(mq.id_cursos)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to import question {}: {}", mq.id_pregunta, e)))?;
imported_questions.push(question);
}
tracing::info!("Imported {} questions from MySQL (skipped {} already imported)", imported_questions.len(), skipped_count);
Ok(Json(imported_questions))
}
// ==================== Audio Generation ====================
/// POST /api/question-bank/{id}/generate-audio - Generate audio for a question using Bark
pub async fn generate_audio(
Org(org_ctx): Org,
Path(id): Path<Uuid>,
State(pool): State<PgPool>,
payload: Option<Json<common::models::GenerateAudioPayload>>,
) -> Result<StatusCode, (StatusCode, String)> {
// Get question
let question: QuestionBank = sqlx::query_as(
"SELECT * FROM question_bank WHERE id = $1 AND organization_id = $2"
)
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Question not found".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
// Spawn async task for audio generation
tokio::spawn(async move {
let _ = generate_audio_for_question_with_params(id, pool, payload.map(|p| p.0)).await;
});
Ok(StatusCode::ACCEPTED)
}
async fn generate_audio_for_question(
question_id: Uuid,
pool: PgPool,
) -> Result<(), String> {
generate_audio_for_question_with_params(question_id, pool, None).await
}
async fn generate_audio_for_question_with_params(
question_id: Uuid,
pool: PgPool,
payload: Option<common::models::GenerateAudioPayload>,
) -> Result<(), String> {
use reqwest::Client;
use serde_json::json;
// Get question text
let question_text: String = sqlx::query_scalar("SELECT audio_text FROM question_bank WHERE id = $1")
.bind(question_id)
.fetch_optional(&pool)
.await
.map_err(|e| format!("Failed to get question: {}", e))?
.unwrap_or_default();
let text = payload.as_ref().map(|p| p.text.clone()).unwrap_or(question_text);
// Update status to generating
sqlx::query("UPDATE question_bank SET audio_status = 'generating' WHERE id = $1")
.bind(question_id)
.execute(&pool)
.await
.map_err(|e| format!("Failed to update status: {}", e))?;
// Call Bark TTS API
let bark_url = std::env::var("BARK_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
let client = Client::new();
let voice = payload.as_ref().and_then(|p| p.voice.clone()).unwrap_or_else(|| "v2/en_speaker_1".to_string());
let speed = payload.as_ref().and_then(|p| p.speed).unwrap_or(1.0);
let response = client
.post(&format!("{}/api/generate", bark_url))
.json(&json!({
"text": text,
"voice": voice,
"speed": speed,
"output_format": "mp3"
}))
.send()
.await
.map_err(|e| format!("Bark API request failed: {}", e))?;
if !response.status().is_success() {
sqlx::query("UPDATE question_bank SET audio_status = 'failed' WHERE id = $1")
.bind(question_id)
.execute(&pool)
.await
.map_err(|_| "Failed to update status".to_string())?;
return Err(format!("Bark API returned error: {}", response.status()));
}
// Save audio file
let audio_bytes = response.bytes().await.map_err(|e| format!("Failed to get audio bytes: {}", e))?;
// Save to uploads directory
let filename = format!("question_{}.mp3", question_id);
let file_path = format!("uploads/audio/{}", filename);
std::fs::create_dir_all("uploads/audio").map_err(|e| format!("Failed to create directory: {}", e))?;
std::fs::write(&file_path, &audio_bytes).map_err(|e| format!("Failed to save audio: {}", e))?;
// Update question with audio URL
let audio_url = format!("/audio/{}", filename);
sqlx::query(
"UPDATE question_bank SET audio_url = $1, audio_status = 'ready', audio_metadata = $2 WHERE id = $3"
)
.bind(&audio_url)
.bind(&json!({
"voice": voice,
"speed": speed,
"generated_at": chrono::Utc::now().to_rfc3339(),
"file_size": audio_bytes.len(),
}))
.bind(question_id)
.execute(&pool)
.await
.map_err(|e| format!("Failed to update question: {}", e))?;
tracing::info!("Generated audio for question {}", question_id);
Ok(())
}
// ==================== Helpers ====================
fn map_mysql_question_type(mysql_type: i32) -> QuestionBankType {
// Map MySQL question types to platform types
// This depends on how tipos are defined in the MySQL database
match mysql_type {
1 => QuestionBankType::MultipleChoice,
2 => QuestionBankType::TrueFalse,
3 => QuestionBankType::ShortAnswer,
4 => QuestionBankType::Matching,
_ => QuestionBankType::MultipleChoice, // Default
}
}
// ==================== MySQL Integration ====================
/// GET /api/question-bank/mysql-courses - List courses from MySQL for import
pub async fn list_mysql_courses(
Org(_org_ctx): Org,
State(_pool): State<PgPool>,
) -> Result<Json<Vec<MySqlCourseInfo>>, (StatusCode, String)> {
// 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()))?;
let mysql_pool = sqlx::MySqlPool::connect(&mysql_url)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to MySQL: {}", e)))?;
// Fetch courses with their plan names
let courses: Vec<MySqlCourseInfo> = sqlx::query_as(
r#"
SELECT DISTINCT
c.idCursos,
c.NombreCurso,
c.NivelCurso,
pe.idPlanDeEstudios,
pe.Nombre as NombrePlan
FROM curso c
JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios
WHERE c.Activo = 1
ORDER BY pe.Nombre, c.NombreCurso
"#
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
mysql_pool.close().await;
Ok(Json(courses))
}
/// POST /api/question-bank/import-mysql-all - Import ALL questions from MySQL (bulk import)
pub async fn import_all_from_mysql(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
) -> Result<Json<ImportResult>, (StatusCode, String)> {
// 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()))?;
let mysql_pool = sqlx::MySqlPool::connect(&mysql_url)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to MySQL: {}", e)))?;
// Fetch ALL questions from MySQL
let mysql_questions: Vec<MySqlQuestionFull> = sqlx::query_as(
r#"
SELECT
bp.idPregunta,
bp.descripcion,
bp.idTipoPregunta,
bp.activo,
c.idCursos,
c.NombreCurso,
c.NivelCurso,
pe.idPlanDeEstudios,
pe.Nombre as PlanNombre
FROM bancopreguntas bp
JOIN curso c ON bp.idCursos = c.idCursos
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
WHERE bp.activo = 1
ORDER BY pe.Nombre, c.NombreCurso, bp.idPregunta
"#
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?;
mysql_pool.close().await;
if mysql_questions.is_empty() {
return Ok(Json(ImportResult {
imported: 0,
skipped: 0,
updated: 0,
error: Some("No questions found in MySQL".to_string()),
}));
}
// Import questions into PostgreSQL
let mut imported_count = 0;
let mut skipped_count = 0;
let mut updated_count = 0;
for mq in mysql_questions {
// Check if question already exists
let existing: Option<(Uuid, bool)> = sqlx::query_as(
"SELECT id, is_active FROM question_bank WHERE imported_mysql_id = $1 AND organization_id = $2"
)
.bind(mq.id_pregunta)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Check failed: {}", e)))?;
match existing {
Some((id, is_active)) => {
// Question exists - update if it was inactive or has new data
if !is_active {
// Reactivate and update
let _ = sqlx::query(
"UPDATE question_bank SET is_active = true, question_text = $3, updated_at = NOW() WHERE id = $1 AND organization_id = $2"
)
.bind(id)
.bind(org_ctx.id)
.bind(&mq.descripcion)
.execute(&pool)
.await;
updated_count += 1;
} else {
skipped_count += 1; // Already exists and active, skip
}
}
None => {
// New question - insert
let question_type = map_mysql_question_type(mq.id_tipo_pregunta);
let options = if question_type == QuestionBankType::MultipleChoice {
Some(serde_json::json!(["Opción A", "Opción B", "Opción C", "Opción D"]))
} else if question_type == QuestionBankType::TrueFalse {
Some(serde_json::json!(["Verdadero", "Falso"]))
} else {
None
};
let source_metadata = serde_json::json!({
"mysql_table": "bancopreguntas",
"idPregunta": mq.id_pregunta,
"idCursos": mq.id_cursos,
"nombre_curso": mq.nombre_curso,
"nivel_curso": mq.nivel_curso,
"idPlanDeEstudios": mq.id_plan_de_estudios,
"plan_nombre": mq.plan_nombre,
"idTipoPregunta": mq.id_tipo_pregunta,
"imported_at": chrono::Utc::now().to_rfc3339(),
"import_method": "bulk_import_all",
});
let _ = sqlx::query(
r#"
INSERT INTO question_bank (
organization_id, created_by, question_text, question_type,
options, correct_answer, source, source_metadata,
imported_mysql_id, imported_mysql_course_id,
audio_status, is_active
)
VALUES ($1, $2, $3, $4, $5, $6, 'imported-mysql', $7, $8, $9, 'pending', true)
"#
)
.bind(org_ctx.id)
.bind(claims.sub)
.bind(&mq.descripcion)
.bind(&question_type)
.bind(&options)
.bind(&serde_json::Value::Null)
.bind(&source_metadata)
.bind(mq.id_pregunta)
.bind(mq.id_cursos)
.execute(&pool)
.await;
imported_count += 1;
}
}
}
tracing::info!(
"Bulk import from MySQL: {} imported, {} skipped, {} updated",
imported_count,
skipped_count,
updated_count
);
Ok(Json(ImportResult {
imported: imported_count,
skipped: skipped_count,
updated: updated_count,
error: None,
}))
}
#[derive(Debug, Serialize)]
pub struct ImportResult {
pub imported: i32,
pub skipped: i32,
pub updated: i32,
pub error: Option<String>,
}
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize)]
pub struct MySqlCourseInfo {
pub id_cursos: i32,
pub nombre_curso: String,
pub nivel_curso: Option<i32>,
pub id_plan_de_estudios: i32,
pub nombre_plan: String,
}
// Excel import - pendiente de fix
// /// POST /api/question-bank/import-excel - Import questions from Excel file
// pub async fn import_from_excel(
// Org(org_ctx): Org,
// claims: Claims,
// State(pool): State<PgPool>,
// multipart: axum::extract::Multipart,
// ) -> Result<Json<ImportResult>, (StatusCode, String)> {
// // Implementation pending
// unimplemented!()
// }
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize)]
pub struct MySqlQuestionFull {
pub id_pregunta: i32,
pub descripcion: String,
pub id_tipo_pregunta: i32,
pub activo: bool,
pub id_cursos: i32,
pub nombre_curso: String,
pub nivel_curso: Option<i32>,
pub id_plan_de_estudios: i32,
pub plan_nombre: String,
}
#[derive(Debug, sqlx::FromRow)]
struct MySqlQuestion {
id_pregunta: i32,
descripcion: String,
id_tipo_pregunta: i32,
activo: bool,
id_cursos: i32,
nombre_curso: String,
id_plan_de_estudios: i32,
plan_nombre: String,
}
@@ -484,8 +484,6 @@ pub async fn apply_template_to_lesson(
State(pool): State<PgPool>,
Json(payload): Json<ApplyTemplatePayload>,
) -> Result<StatusCode, (StatusCode, String)> {
use common::models::ApplyTemplatePayload;
// Verify template exists and belongs to organization
let template: TestTemplate = sqlx::query_as(
r#"
@@ -519,18 +517,92 @@ pub async fn apply_template_to_lesson(
return Err((StatusCode::NOT_FOUND, "Lesson not found".to_string()));
}
// Update lesson with template data
// This would typically involve:
// 1. Setting lesson content_type to "quiz" or "test"
// 2. Setting lesson metadata with template_data
// 3. Optionally linking to a grading category
// For now, we just increment the usage count
// Get template questions with their sections
let template_questions: Vec<TestTemplateQuestion> = sqlx::query_as(
r#"
SELECT id, template_id, section_id, question_order, question_type, question_text,
options, correct_answer, explanation, points, metadata, created_at
FROM test_template_questions
WHERE template_id = $1
ORDER BY section_id, question_order
"#
)
.bind(template_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if template_questions.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Template has no questions".to_string()));
}
// Build quiz_data JSON from template questions
let questions_json: Vec<serde_json::Value> = template_questions
.iter()
.map(|q| {
serde_json::json!({
"id": q.id.to_string(),
"type": q.question_type,
"question": q.question_text,
"options": q.options.clone().unwrap_or(serde_json::Value::Null),
"correct": q.correct_answer.clone().unwrap_or(serde_json::Value::Null),
"explanation": q.explanation.clone().unwrap_or_default(),
"points": q.points,
})
})
.collect();
let quiz_data = serde_json::json!({
"questions": questions_json,
"template_id": template_id.to_string(),
"template_name": template.name,
"test_type": template.test_type.to_string(),
"duration_minutes": template.duration_minutes,
"passing_score": template.passing_score,
"total_points": template.total_points,
"instructions": template.instructions,
"max_attempts": 1, // Single attempt as requested
"show_feedback": true, // Show explanations after answering
"permanent_history": true, // Student can always view their responses
});
// Update lesson with quiz data and configuration
sqlx::query(
r#"
UPDATE lessons
SET content_type = 'quiz',
content_url = NULL,
metadata = $1,
is_graded = true,
max_attempts = 1,
allow_retry = false,
grading_category_id = $2,
updated_at = NOW()
WHERE id = $3 AND organization_id = $4
"#
)
.bind(&quiz_data)
.bind(payload.grading_category_id)
.bind(payload.lesson_id)
.bind(org_ctx.id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Increment template usage count
sqlx::query("SELECT increment_template_usage($1)")
.bind(template_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::info!(
"Applied template '{}' to lesson '{}' with {} questions",
template.name,
payload.lesson_id,
template_questions.len()
);
Ok(StatusCode::OK)
}
@@ -539,3 +611,244 @@ pub struct ApplyTemplatePayload {
pub lesson_id: Uuid,
pub grading_category_id: Option<Uuid>,
}
// ==================== RAG Question Generation ====================
/// POST /api/test-templates/generate-with-rag - Generate questions using RAG from MySQL question bank
pub async fn generate_questions_with_rag(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Json(payload): Json<RagGenerationPayload>,
) -> Result<Json<Vec<TestTemplateQuestion>>, (StatusCode, String)> {
use serde_json::json;
// 1. Fetch questions from external MySQL database (RAG context)
let mysql_url = std::env::var("MYSQL_DATABASE_URL")
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "MYSQL_DATABASE_URL not configured".to_string()))?;
// Create MySQL pool connection
let mysql_pool = sqlx::MySqlPool::connect(&mysql_url)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to MySQL: {}", e)))?;
// Fetch questions from MySQL bank filtered by course if provided
let mysql_questions: Vec<MySqlQuestion> = if let Some(course_id) = payload.course_id {
sqlx::query_as(
r#"
SELECT bp.descripcion, bp.idTipoPregunta, c.NombreCurso, pe.Nombre as PlanNombre
FROM bancopreguntas bp
JOIN curso c ON bp.idCursos = c.idCursos
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
WHERE bp.idCursos = ? AND bp.activo = 1
LIMIT 50
"#
)
.bind(course_id)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
} else {
sqlx::query_as(
r#"
SELECT bp.descripcion, bp.idTipoPregunta, c.NombreCurso, pe.Nombre as PlanNombre
FROM bancopreguntas bp
JOIN curso c ON bp.idCursos = c.idCursos
JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios
WHERE bp.activo = 1
LIMIT 50
"#
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
};
mysql_pool.close().await;
if mysql_questions.is_empty() && payload.course_id.is_some() {
return Err((StatusCode::NOT_FOUND, "No questions found in MySQL bank for this course".to_string()));
}
// 2. Build RAG context from MySQL questions
let rag_context: String = mysql_questions
.iter()
.map(|q| format!("- {} (Tipo: {}, Curso: {})", q.descripcion, q.id_tipo_pregunta, q.nombre_curso))
.collect::<Vec<_>>()
.join("\n");
// 3. Call AI to generate new questions based on RAG context
let provider = std::env::var("AI_PROVIDER").unwrap_or_else(|_| "local".to_string());
let client = reqwest::Client::new();
let (url, auth_header, model) = if provider == "local" {
let base_url = std::env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
(
format!("{}/v1/chat/completions", base_url),
"".to_string(),
model,
)
} else {
let api_key = std::env::var("OPENAI_API_KEY")
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "OPENAI_API_KEY not configured".to_string()))?;
(
"https://api.openai.com/v1/chat/completions".to_string(),
format!("Bearer {}", api_key),
"gpt-4o".to_string(),
)
};
let system_prompt = format!(
r#"You are an expert English Teacher creating ORIGINAL quiz questions that assess the FOUR KEY LANGUAGE SKILLS.
Below are EXAMPLE questions from our existing question bank. Use them as INSPIRATION ONLY:
- Understand the TOPICS, STYLE, and DIFFICULTY LEVEL
- Create COMPLETELY NEW questions with different wording, contexts, and answer options
- Do NOT copy any question directly
- Adapt the concepts to fresh scenarios
EXAMPLE QUESTIONS (for inspiration only):
{}
Task: Create {} ORIGINAL multiple-choice questions about: {}
IMPORTANT: Each question must assess ONE of these FOUR skills:
1. READING - Comprehension, vocabulary in context, text analysis
2. LISTENING - Audio comprehension, spoken dialogue understanding
3. SPEAKING - Oral production, pronunciation, conversational response
4. WRITING - Written production, grammar in writing, composition
For EACH question, you MUST:
- Specify which skill it assesses (reading/listening/speaking/writing)
- Ensure the question actually tests that skill (not just knowledge)
- Provide pedagogically sound content for English language learning
- Match the difficulty level appropriately
Return ONLY a JSON array of questions with this exact structure:
[
{{
"question_text": "Your ORIGINAL question text here",
"question_type": "multiple-choice",
"options": ["Option A", "Option B", "Option C", "Option D"],
"correct_answer": 0,
"explanation": "Brief explanation of why this is correct. End with: 'Skill assessed: [READING|LISTENING|SPEAKING|WRITING]'",
"points": 1,
"skill_assessed": "reading"
}}
]
GUIDELINES:
- Each question must be UNIQUE - rephrase concepts from examples
- Use different vocabulary, scenarios, and contexts
- Maintain similar difficulty level to the examples
- Ensure correct_answer is the index (0-3) of the correct option
- Write clear explanations that teach, not just state the answer
- DISTRIBUTE questions across all 4 skills (don't focus on just one)
Example transformations by skill:
- READING: "Read this passage and answer..." or "What does the word X mean in context?"
- LISTENING: "After listening to the dialogue..." or "What would you hear in this situation?"
- SPEAKING: "Which response is most appropriate in this conversation?"
- WRITING: "Which sentence is grammatically correct?" or "Complete the sentence with..."
Be creative while maintaining educational value and ensuring all 4 skills are covered!"#,
rag_context,
payload.num_questions.unwrap_or(5),
payload.topic.unwrap_or_else(|| "English grammar and vocabulary".to_string())
);
let mut request = client
.post(&url)
.json(&json!({
"model": model,
"messages": [
{
"role": "system",
"content": system_prompt
}
],
"response_format": { "type": "json_object" }
}));
if !auth_header.is_empty() {
request = request.header("Authorization", auth_header);
}
let response = request.send().await.map_err(|e| {
tracing::error!("AI request failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "AI service unavailable".to_string())
})?;
let response_json: serde_json::Value = response.json().await.map_err(|e| {
tracing::error!("Failed to parse AI response: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Invalid AI response".to_string())
})?;
// Parse questions from AI response
let questions_data = response_json
.get("choices")
.and_then(|c| c.as_array())
.and_then(|choices| choices.first())
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|content| serde_json::from_str::<serde_json::Value>(content.as_str().unwrap_or("{}")).ok())
.and_then(|data| {
if let Some(questions) = data.get("questions").or(data.get("items")) {
questions.as_array().cloned()
} else if let Some(arr) = data.as_array() {
Some(arr.clone())
} else {
None
}
})
.unwrap_or_default();
// Convert to TestTemplateQuestion format
let generated_questions: Vec<TestTemplateQuestion> = questions_data
.iter()
.enumerate()
.map(|(idx, q)| {
TestTemplateQuestion {
id: Uuid::new_v4(),
template_id: Uuid::nil(),
section_id: None,
question_order: idx as i32,
question_type: q.get("question_type").and_then(|v| v.as_str()).unwrap_or("multiple-choice").to_string(),
question_text: q.get("question_text").and_then(|v| v.as_str()).unwrap_or("Question").to_string(),
options: q.get("options").cloned(),
correct_answer: q.get("correct_answer").or(q.get("correct")).cloned(),
explanation: q.get("explanation").and_then(|v| v.as_str()).map(String::from),
points: q.get("points").and_then(|v| v.as_i64()).unwrap_or(1) as i32,
metadata: Some(json!({
"generated_by": "rag-ai",
"source": "mysql-bank",
"generated_at": chrono::Utc::now().to_rfc3339(),
})),
created_at: chrono::Utc::now(),
}
})
.collect();
if generated_questions.is_empty() {
return Err((StatusCode::INTERNAL_SERVER_ERROR, "AI failed to generate questions".to_string()));
}
tracing::info!("Generated {} questions using RAG from MySQL bank", generated_questions.len());
Ok(Json(generated_questions))
}
#[derive(Debug, Deserialize)]
pub struct RagGenerationPayload {
pub course_id: Option<i32>, // MySQL course ID
pub topic: Option<String>,
pub num_questions: Option<i32>,
}
#[derive(Debug, sqlx::FromRow)]
struct MySqlQuestion {
descripcion: String,
id_tipo_pregunta: i32,
nombre_curso: String,
plan_nombre: String,
}
+48
View File
@@ -8,6 +8,8 @@ mod handlers_dependencies;
mod handlers_library;
mod handlers_rubrics;
mod handlers_test_templates;
mod handlers_question_bank;
mod handlers_admin;
mod webhooks;
use axum::{
@@ -321,6 +323,48 @@ async fn main() {
"/test-templates/{id}/apply",
post(handlers_test_templates::apply_template_to_lesson),
)
.route(
"/test-templates/generate-with-rag",
post(handlers_test_templates::generate_questions_with_rag),
)
// Question Bank routes
.route(
"/question-bank",
get(handlers_question_bank::list_questions)
.post(handlers_question_bank::create_question),
)
.route(
"/question-bank/{id}",
get(handlers_question_bank::get_question)
.put(handlers_question_bank::update_question)
.delete(handlers_question_bank::delete_question),
)
.route(
"/question-bank/import-mysql",
post(handlers_question_bank::import_from_mysql),
)
.route(
"/question-bank/{id}/generate-audio",
post(handlers_question_bank::generate_audio),
)
.route(
"/question-bank/mysql-courses",
get(handlers_question_bank::list_mysql_courses),
)
.route(
"/question-bank/import-mysql-all",
post(handlers_question_bank::import_all_from_mysql),
)
// Excel import - pendiente de fix
// .route(
// "/question-bank/import-excel",
// post(handlers_question_bank::import_from_excel),
// )
// Admin routes
.route(
"/admin/token-usage",
get(handlers_admin::get_token_usage),
)
.route_layer(middleware::from_fn(
common::middleware::org_extractor_middleware,
));
@@ -348,6 +392,10 @@ async fn main() {
.route(
"/branding",
get(handlers_branding::get_organization_branding),
)
.route(
"/organization",
get(handlers::get_public_organization),
);
let public_routes = Router::new()