feat: token count implement
This commit is contained in:
@@ -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';
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user