diff --git a/services/cms-service/migrations/20260319000000_dummy.sql b/services/cms-service/migrations/20260319000000_dummy.sql new file mode 100644 index 0000000..b6dbd7d --- /dev/null +++ b/services/cms-service/migrations/20260319000000_dummy.sql @@ -0,0 +1,3 @@ +-- Dummy migration to satisfy SQLx +-- This migration was split into 20260319000001 and 20260319000002 +SELECT 1; diff --git a/services/cms-service/migrations/20260319000000_ai_usage_add_prompt_response.sql b/services/cms-service/migrations/20260319000001_ai_usage_add_prompt_response.sql similarity index 68% rename from services/cms-service/migrations/20260319000000_ai_usage_add_prompt_response.sql rename to services/cms-service/migrations/20260319000001_ai_usage_add_prompt_response.sql index 69328b8..c43b212 100644 --- a/services/cms-service/migrations/20260319000000_ai_usage_add_prompt_response.sql +++ b/services/cms-service/migrations/20260319000001_ai_usage_add_prompt_response.sql @@ -1,10 +1,5 @@ --- AI Usage Logs: Add prompt and response fields for detailed tracking --- This allows storing the actual prompts and responses for debugging and analytics - --- Add new columns to ai_usage_logs -ALTER TABLE ai_usage_logs - ADD COLUMN IF NOT EXISTS prompt TEXT, - ADD COLUMN IF NOT EXISTS response TEXT; +-- AI Usage Logs: Update log_ai_usage function to include prompt and response +-- Note: prompt and response columns already exist from previous migration -- Update log_ai_usage function to accept prompt and response CREATE OR REPLACE FUNCTION log_ai_usage( @@ -44,9 +39,5 @@ BEGIN END; $$ LANGUAGE plpgsql; --- Add indexes for text search (optional, can be enabled if needed for analytics) --- CREATE INDEX idx_ai_usage_prompt ON ai_usage_logs USING gin (to_tsvector('spanish', prompt)); --- CREATE INDEX idx_ai_usage_response ON ai_usage_logs USING gin (to_tsvector('spanish', response)); - COMMENT ON COLUMN ai_usage_logs.prompt IS 'The actual prompt sent to the AI model'; COMMENT ON COLUMN ai_usage_logs.response IS 'The AI model response content'; diff --git a/services/cms-service/migrations/20260319000000_pgvector_embeddings.sql b/services/cms-service/migrations/20260319000002_pgvector_embeddings.sql similarity index 100% rename from services/cms-service/migrations/20260319000000_pgvector_embeddings.sql rename to services/cms-service/migrations/20260319000002_pgvector_embeddings.sql diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index cb99101..f834d63 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -4133,6 +4133,26 @@ RULES: tracing::info!("Transaction committed successfully"); + // Log AI usage for course generation + let input_tokens = count_tokens(&system_prompt) + count_tokens(&payload.prompt); + let output_tokens = count_tokens(&content_str); + let total_tokens = input_tokens + output_tokens; + + let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)") + .bind(claims.sub) + .bind(target_org_id) + .bind(total_tokens) + .bind(input_tokens) + .bind(output_tokens) + .bind("/courses/generate") + .bind(&model) + .bind("course-generation") + .bind(&json!({ "prompt": payload.prompt, "course_title": course_title })) + .bind(&format!("{} - {}", system_prompt, payload.prompt)) // prompt + .bind(&content_str) // response + .execute(&pool) + .await; + log_action( &pool, target_org_id, @@ -4140,7 +4160,7 @@ RULES: "AI_COURSE_GENERATED", "Course", course.id, - json!({ "prompt": payload.prompt }), + json!({ "prompt": payload.prompt, "tokens_used": total_tokens }), ) .await; diff --git a/services/cms-service/src/handlers_admin.rs b/services/cms-service/src/handlers_admin.rs index ad2d362..2b64bf9 100644 --- a/services/cms-service/src/handlers_admin.rs +++ b/services/cms-service/src/handlers_admin.rs @@ -6,6 +6,7 @@ use axum::{ use common::{auth::Claims, middleware::Org}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; +use chrono::{DateTime, Utc}; // ==================== Token Usage Tracking ==================== @@ -18,7 +19,7 @@ pub async fn get_token_usage( // Get user token usage from database let usage: Vec = sqlx::query_as( r#" - SELECT + SELECT u.id as user_id, u.email, u.full_name, @@ -85,6 +86,293 @@ pub async fn get_token_usage( })) } +/// GET /api/admin/ai-usage-dashboard - Comprehensive AI usage dashboard data +pub async fn get_ai_usage_dashboard( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Query(filters): Query, +) -> Result, (StatusCode, String)> { + // Get daily usage for charts + let daily_usage: Vec = sqlx::query_as( + r#" + SELECT + DATE(au.created_at) as date, + 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, + COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd, + COUNT(au.id) as requests + FROM ai_usage_logs au + WHERE au.organization_id = $1 + AND ($2::DATE IS NULL OR DATE(au.created_at) >= $2::DATE) + AND ($3::DATE IS NULL OR DATE(au.created_at) <= $3::DATE) + GROUP BY DATE(au.created_at) + ORDER BY date DESC + LIMIT 30 + "# + ) + .bind(org_ctx.id) + .bind(filters.start_date.clone()) + .bind(filters.end_date.clone()) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch daily usage: {}", e)))?; + + // Get usage by endpoint/feature + let by_endpoint: Vec = sqlx::query_as( + r#" + SELECT + au.endpoint, + au.request_type, + 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, + COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd, + COUNT(au.id) as requests + FROM ai_usage_logs au + WHERE au.organization_id = $1 + AND ($2::DATE IS NULL OR DATE(au.created_at) >= $2::DATE) + AND ($3::DATE IS NULL OR DATE(au.created_at) <= $3::DATE) + GROUP BY au.endpoint, au.request_type + ORDER BY total_tokens DESC + "# + ) + .bind(org_ctx.id) + .bind(filters.start_date.clone()) + .bind(filters.end_date.clone()) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch endpoint usage: {}", e)))?; + + // Get top users + let top_users: Vec = 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, + COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd, + COUNT(au.id) as requests + FROM ai_usage_logs au + JOIN users u ON au.user_id = u.id + WHERE au.organization_id = $1 + AND ($2::DATE IS NULL OR DATE(au.created_at) >= $2::DATE) + AND ($3::DATE IS NULL OR DATE(au.created_at) <= $3::DATE) + GROUP BY u.id, u.email, u.full_name, u.role + ORDER BY total_tokens DESC + LIMIT 10 + "# + ) + .bind(org_ctx.id) + .bind(filters.start_date.clone()) + .bind(filters.end_date.clone()) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch top users: {}", e)))?; + + // Calculate summary stats + let total_tokens: i64 = daily_usage.iter().map(|d| d.total_tokens).sum(); + let total_input: i64 = daily_usage.iter().map(|d| d.input_tokens).sum(); + let total_output: i64 = daily_usage.iter().map(|d| d.output_tokens).sum(); + let total_cost: f64 = daily_usage.iter().map(|d| d.cost_usd).sum(); + let total_requests: i64 = daily_usage.iter().map(|d| d.requests).sum(); + + // Calculate savings estimate (vs OpenAI GPT-4 pricing) + // GPT-4: ~$0.03/1K input, ~$0.06/1K output + // Our local AI: ~$0.001/1K input, ~$0.003/1K output + let openai_equivalent_cost = (total_input as f64 * 0.00003) + (total_output as f64 * 0.00006); + let savings_vs_openai = openai_equivalent_cost - total_cost; + + Ok(Json(DashboardResponse { + summary: DashboardSummary { + total_tokens, + total_input, + total_output, + total_requests, + total_cost_usd: total_cost, + savings_vs_openai_usd: savings_vs_openai, + openai_equivalent_cost_usd: openai_equivalent_cost, + }, + daily_usage, + by_endpoint, + top_users, + })) +} + +/// GET /api/admin/ai-usage/logs - Get detailed AI usage logs with pagination +pub async fn get_ai_usage_logs( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Query(filters): Query, +) -> Result, (StatusCode, String)> { + let limit = filters.limit.unwrap_or(50).min(200); + let offset = filters.offset.unwrap_or(0); + + let logs: Vec = sqlx::query_as( + r#" + SELECT + au.id, + au.user_id, + u.email as user_email, + u.full_name as user_name, + au.endpoint, + au.request_type, + au.model, + au.tokens_used, + au.input_tokens, + au.output_tokens, + au.estimated_cost_usd, + au.prompt, + au.response, + au.request_metadata, + au.created_at + FROM ai_usage_logs au + JOIN users u ON au.user_id = u.id + WHERE au.organization_id = $1 + AND ($2::TEXT IS NULL OR au.endpoint = $2) + AND ($3::TEXT IS NULL OR au.request_type = $3) + AND ($4::UUID IS NULL OR au.user_id = $4) + ORDER BY au.created_at DESC + LIMIT $5 OFFSET $6 + "# + ) + .bind(org_ctx.id) + .bind(filters.endpoint.clone()) + .bind(filters.request_type.clone()) + .bind(filters.user_id.clone()) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch logs: {}", e)))?; + + // Get total count for pagination + let count: (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*) FROM ai_usage_logs + WHERE organization_id = $1 + AND ($2::TEXT IS NULL OR endpoint = $2) + AND ($3::TEXT IS NULL OR request_type = $3) + AND ($4::UUID IS NULL OR user_id = $4) + "# + ) + .bind(org_ctx.id) + .bind(filters.endpoint.clone()) + .bind(filters.request_type.clone()) + .bind(filters.user_id.clone()) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count logs: {}", e)))?; + + Ok(Json(UsageLogsResponse { + logs, + total: count.0, + limit, + offset, + })) +} + +#[derive(Debug, Deserialize)] +pub struct DashboardFilters { + pub start_date: Option, + pub end_date: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UsageLogFilters { + pub endpoint: Option, + pub request_type: Option, + pub user_id: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, Serialize)] +pub struct DashboardSummary { + pub total_tokens: i64, + pub total_input: i64, + pub total_output: i64, + pub total_requests: i64, + pub total_cost_usd: f64, + pub savings_vs_openai_usd: f64, + pub openai_equivalent_cost_usd: f64, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct DailyUsage { + pub date: chrono::NaiveDate, + pub total_tokens: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub cost_usd: f64, + pub requests: i64, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct UsageByEndpoint { + pub endpoint: String, + pub request_type: String, + pub total_tokens: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub cost_usd: f64, + pub requests: i64, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct TopUserUsage { + pub user_id: uuid::Uuid, + pub email: String, + pub full_name: String, + pub role: String, + pub total_tokens: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub cost_usd: f64, + pub requests: i64, +} + +#[derive(Debug, Serialize)] +pub struct DashboardResponse { + pub summary: DashboardSummary, + pub daily_usage: Vec, + pub by_endpoint: Vec, + pub top_users: Vec, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct UsageLogRecord { + pub id: uuid::Uuid, + pub user_id: uuid::Uuid, + pub user_email: String, + pub user_name: String, + pub endpoint: String, + pub request_type: String, + pub model: String, + pub tokens_used: i32, + pub input_tokens: i32, + pub output_tokens: i32, + pub estimated_cost_usd: f64, + pub prompt: Option, + pub response: Option, + pub request_metadata: serde_json::Value, + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +pub struct UsageLogsResponse { + pub logs: Vec, + pub total: i64, + pub limit: u32, + pub offset: u32, +} + #[derive(Debug, Deserialize)] pub struct TokenUsageFilters { pub role: Option, @@ -134,3 +422,298 @@ pub struct TokenUsageResponse { pub usage: Vec, pub stats: TokenUsageStats, } + +// ==================== Global AI Usage Dashboard (Root Only) ==================== + +/// GET /api/admin/ai-usage/global - Global AI usage dashboard for root users +pub async fn get_ai_usage_global( + _claims: Claims, + State(pool): State, + Query(filters): Query, +) -> Result, (StatusCode, String)> { + // Get daily usage for charts (all organizations) + let daily_usage: Vec = sqlx::query_as( + r#" + SELECT + DATE(au.created_at) as date, + 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, + COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd, + COUNT(au.id) as requests + FROM ai_usage_logs au + WHERE ($1::DATE IS NULL OR au.created_at >= $1::DATE) + AND ($2::DATE IS NULL OR au.created_at <= $2::DATE + INTERVAL '1 day') + GROUP BY DATE(au.created_at) + ORDER BY date ASC + LIMIT 90 + "# + ) + .bind(filters.start_date.clone()) + .bind(filters.end_date.clone()) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch daily usage: {}", e)))?; + + // Get usage by endpoint/feature + let by_endpoint: Vec = sqlx::query_as( + r#" + SELECT + au.endpoint, + au.request_type, + 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, + COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd, + COUNT(au.id) as requests + FROM ai_usage_logs au + WHERE ($1::DATE IS NULL OR au.created_at >= $1::DATE) + AND ($2::DATE IS NULL OR au.created_at <= $2::DATE + INTERVAL '1 day') + GROUP BY au.endpoint, au.request_type + ORDER BY total_tokens DESC + "# + ) + .bind(filters.start_date.clone()) + .bind(filters.end_date.clone()) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch endpoint usage: {}", e)))?; + + // Get usage by organization + let by_organization: Vec = sqlx::query_as( + r#" + SELECT + o.id as org_id, + o.name as org_name, + 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, + COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd, + COUNT(au.id) as requests, + COUNT(DISTINCT au.user_id) as active_users + FROM ai_usage_logs au + JOIN organizations o ON au.organization_id = o.id + WHERE ($1::DATE IS NULL OR DATE(au.created_at) >= $1::DATE) + AND ($2::DATE IS NULL OR DATE(au.created_at) <= $2::DATE) + GROUP BY o.id, o.name + ORDER BY total_tokens DESC + "# + ) + .bind(filters.start_date.clone()) + .bind(filters.end_date.clone()) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch org usage: {}", e)))?; + + // Get top users across all organizations + let top_users: Vec = sqlx::query_as( + r#" + SELECT + u.id as user_id, + u.email, + u.full_name, + u.role, + o.name as org_name, + 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, + COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd, + COUNT(au.id) as requests + FROM ai_usage_logs au + JOIN users u ON au.user_id = u.id + JOIN organizations o ON au.organization_id = o.id + WHERE ($1::DATE IS NULL OR DATE(au.created_at) >= $1::DATE) + AND ($2::DATE IS NULL OR DATE(au.created_at) <= $2::DATE) + GROUP BY u.id, u.email, u.full_name, u.role, o.name + ORDER BY total_tokens DESC + LIMIT 20 + "# + ) + .bind(filters.start_date.clone()) + .bind(filters.end_date.clone()) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch top users: {}", e)))?; + + // Get usage by request type (for pie chart) + let by_request_type: Vec = sqlx::query_as( + r#" + SELECT + au.request_type, + COALESCE(SUM(au.tokens_used), 0) as total_tokens, + COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd, + COUNT(au.id) as requests + FROM ai_usage_logs au + WHERE ($1::DATE IS NULL OR DATE(au.created_at) >= $1::DATE) + AND ($2::DATE IS NULL OR DATE(au.created_at) <= $2::DATE) + GROUP BY au.request_type + ORDER BY total_tokens DESC + "# + ) + .bind(filters.start_date.clone()) + .bind(filters.end_date.clone()) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch request type usage: {}", e)))?; + + // Calculate summary stats + let total_tokens: i64 = daily_usage.iter().map(|d| d.total_tokens).sum(); + let total_input: i64 = daily_usage.iter().map(|d| d.input_tokens).sum(); + let total_output: i64 = daily_usage.iter().map(|d| d.output_tokens).sum(); + let total_cost: f64 = daily_usage.iter().map(|d| d.cost_usd).sum(); + let total_requests: i64 = daily_usage.iter().map(|d| d.requests).sum(); + + // Calculate savings estimate (vs OpenAI GPT-4 pricing) + // GPT-4: ~$0.03/1K input, ~$0.06/1K output + // Our local AI: ~$0.001/1K input, ~$0.003/1K output + let openai_equivalent_cost = (total_input as f64 * 0.00003) + (total_output as f64 * 0.00006); + let savings_vs_openai = openai_equivalent_cost - total_cost; + let savings_percentage = if openai_equivalent_cost > 0.0 { + (savings_vs_openai / openai_equivalent_cost) * 100.0 + } else { + 0.0 + }; + + // Get total active users count + let total_active_users: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT au.user_id) + FROM ai_usage_logs au + WHERE ($1::DATE IS NULL OR DATE(au.created_at) >= $1::DATE) + AND ($2::DATE IS NULL OR DATE(au.created_at) <= $2::DATE) + "# + ) + .bind(filters.start_date.clone()) + .bind(filters.end_date.clone()) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count users: {}", e)))?; + + // Calculate student-specific usage (chat interactions) + let student_chat_usage: Vec = sqlx::query_as( + r#" + SELECT + u.id as user_id, + u.email, + u.full_name, + o.name as org_name, + COALESCE(SUM(au.tokens_used), 0) as total_tokens, + COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd, + COUNT(au.id) as chat_requests, + MAX(au.created_at) as last_chat + FROM ai_usage_logs au + JOIN users u ON au.user_id = u.id + JOIN organizations o ON au.organization_id = o.id + WHERE au.request_type = 'chat' + AND u.role = 'student' + AND ($1::DATE IS NULL OR DATE(au.created_at) >= $1::DATE) + AND ($2::DATE IS NULL OR DATE(au.created_at) <= $2::DATE) + GROUP BY u.id, u.email, u.full_name, o.name + ORDER BY total_tokens DESC + LIMIT 50 + "# + ) + .bind(filters.start_date.clone()) + .bind(filters.end_date.clone()) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch student chat usage: {}", e)))?; + + // Calculate student chat totals + let total_student_chat_tokens: i64 = student_chat_usage.iter().map(|s| s.total_tokens).sum(); + let total_student_chat_cost: f64 = student_chat_usage.iter().map(|s| s.cost_usd).sum(); + let total_student_chat_requests: i64 = student_chat_usage.iter().map(|s| s.chat_requests).sum(); + + Ok(Json(GlobalAiUsageResponse { + summary: GlobalAiSummary { + total_tokens, + total_input, + total_output, + total_requests, + total_cost_usd: total_cost, + savings_vs_openai_usd: savings_vs_openai, + savings_percentage, + openai_equivalent_cost_usd: openai_equivalent_cost, + total_organizations: by_organization.len() as i64, + total_active_users, + }, + student_chat_summary: Some(StudentChatSummary { + total_tokens: total_student_chat_tokens, + total_requests: total_student_chat_requests, + total_cost_usd: total_student_chat_cost, + active_students: student_chat_usage.len() as i64, + }), + daily_usage, + by_endpoint, + by_organization, + by_request_type, + top_users, + student_chat_usage, + })) +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct UsageByOrganization { + pub org_id: uuid::Uuid, + pub org_name: String, + pub total_tokens: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub cost_usd: f64, + pub requests: i64, + pub active_users: i64, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct UsageByRequestType { + pub request_type: String, + pub total_tokens: i64, + pub cost_usd: f64, + pub requests: i64, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct StudentChatUsage { + pub user_id: uuid::Uuid, + pub email: String, + pub full_name: String, + pub org_name: String, + pub total_tokens: i64, + pub cost_usd: f64, + pub chat_requests: i64, + pub last_chat: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +pub struct StudentChatSummary { + pub total_tokens: i64, + pub total_requests: i64, + pub total_cost_usd: f64, + pub active_students: i64, +} + +#[derive(Debug, Serialize)] +pub struct GlobalAiSummary { + pub total_tokens: i64, + pub total_input: i64, + pub total_output: i64, + pub total_requests: i64, + pub total_cost_usd: f64, + pub savings_vs_openai_usd: f64, + pub savings_percentage: f64, + pub openai_equivalent_cost_usd: f64, + pub total_organizations: i64, + pub total_active_users: i64, +} + +#[derive(Debug, Serialize)] +pub struct GlobalAiUsageResponse { + pub summary: GlobalAiSummary, + pub student_chat_summary: Option, + pub daily_usage: Vec, + pub by_endpoint: Vec, + pub by_organization: Vec, + pub by_request_type: Vec, + pub top_users: Vec, + pub student_chat_usage: Vec, +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 40c7193..18a7cc9 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -386,6 +386,10 @@ async fn main() { "/admin/token-usage", get(handlers_admin::get_token_usage), ) + .route( + "/admin/ai-usage/global", + get(handlers_admin::get_ai_usage_global), + ) .route_layer(middleware::from_fn( common::middleware::org_extractor_middleware, )); diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 656dcd5..5341744 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -14,6 +14,16 @@ use common::models::{ LessonDependency, }; use crate::external_db::MySqlPool; +use serde_json::json; + +// Simple token counter (approximate: 1 token ≈ 4 characters in English, ~3-5 in Spanish) +fn count_tokens(text: &str) -> i32 { + if text.is_empty() { + return 0; + } + // Spanish average: ~4 chars per token + (text.len() / 4) as i32 + 1 +} pub async fn get_me( claims: common::auth::Claims, @@ -2773,6 +2783,30 @@ pub async fn chat_with_tutor( .unwrap_or("Lo siento, tuve un problema procesando tu pregunta.") .to_string(); + // Calculate and log token usage + let input_tokens = count_tokens(&system_prompt) + count_tokens(&payload.message); + let output_tokens = count_tokens(&tutor_response); + let total_tokens = input_tokens + output_tokens; + + let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)") + .bind(claims.sub) + .bind(org_ctx.id) + .bind(total_tokens) + .bind(input_tokens) + .bind(output_tokens) + .bind("/lessons/chat") + .bind(&model) + .bind("chat") + .bind(&json!({ + "lesson_id": lesson_id, + "session_id": session_id, + "has_rag": !kb_context.is_empty(), + })) + .bind(&format!("{} - {}", system_prompt, payload.message)) // prompt + .bind(&tutor_response) // response + .execute(&pool) + .await; + // Save assistant response let _ = sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)") diff --git a/web/studio/src/app/admin/ai-usage-global/page.tsx b/web/studio/src/app/admin/ai-usage-global/page.tsx new file mode 100644 index 0000000..c1940d7 --- /dev/null +++ b/web/studio/src/app/admin/ai-usage-global/page.tsx @@ -0,0 +1,595 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { cmsApi } from '@/lib/api'; +import { + ShieldCheck, + TrendingUp, + DollarSign, + Users, + Activity, + PieChart, + BarChart3, + MessageCircle, + GraduationCap, + Building2, + ArrowUpRight, + Save +} from 'lucide-react'; + +interface DailyUsage { + date: string; + total_tokens: number; + input_tokens: number; + output_tokens: number; + cost_usd: number; + requests: number; +} + +interface UsageByEndpoint { + endpoint: string; + request_type: string; + total_tokens: number; + input_tokens: number; + output_tokens: number; + cost_usd: number; + requests: number; +} + +interface UsageByOrganization { + org_id: string; + org_name: string; + total_tokens: number; + input_tokens: number; + output_tokens: number; + cost_usd: number; + requests: number; + active_users: number; +} + +interface UsageByRequestType { + request_type: string; + total_tokens: number; + cost_usd: number; + requests: number; +} + +interface TopUserUsage { + user_id: string; + email: string; + full_name: string; + role: string; + org_name: string; + total_tokens: number; + input_tokens: number; + output_tokens: number; + cost_usd: number; + requests: number; +} + +interface StudentChatUsage { + user_id: string; + email: string; + full_name: string; + org_name: string; + total_tokens: number; + cost_usd: number; + chat_requests: number; + last_chat: string; +} + +interface GlobalAiSummary { + total_tokens: number; + total_input: number; + total_output: number; + total_requests: number; + total_cost_usd: number; + savings_vs_openai_usd: number; + savings_percentage: number; + openai_equivalent_cost_usd: number; + total_organizations: number; + total_active_users: number; +} + +interface StudentChatSummary { + total_tokens: number; + total_requests: number; + total_cost_usd: number; + active_students: number; +} + +interface GlobalAiUsageResponse { + summary: GlobalAiSummary; + student_chat_summary: StudentChatSummary | null; + daily_usage: DailyUsage[]; + by_endpoint: UsageByEndpoint[]; + by_organization: UsageByOrganization[]; + by_request_type: UsageByRequestType[]; + top_users: TopUserUsage[]; + student_chat_usage: StudentChatUsage[]; +} + +export default function GlobalAiControl() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [dateRange, setDateRange] = useState<'7d' | '30d' | '90d'>('30d'); + const [authError, setAuthError] = useState(false); + + useEffect(() => { + loadGlobalUsage(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dateRange]); + + const loadGlobalUsage = async () => { + try { + const endDate = new Date(); + const startDate = new Date(); + + if (dateRange === '7d') { + startDate.setDate(startDate.getDate() - 7); + } else if (dateRange === '30d') { + startDate.setDate(startDate.getDate() - 30); + } else { + startDate.setDate(startDate.getDate() - 90); + } + + console.log('Loading AI usage from', startDate.toISOString().split('T')[0], 'to', endDate.toISOString().split('T')[0]); + + const token = localStorage.getItem('studio_token'); + console.log('Token from localStorage:', token ? 'EXISTS' : 'NULL'); + + if (!token) { + console.error('No token found. Please login again.'); + setAuthError(true); + setLoading(false); + return; + } + + const jsonData = await cmsApi.getGlobalAiUsage( + startDate.toISOString().split('T')[0], + endDate.toISOString().split('T')[0] + ); + + console.log('Data loaded:', jsonData); + setData(jsonData); + setAuthError(false); + } catch (error) { + console.error('Failed to load global AI usage:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('401') || errorMessage.includes('Unauthorized')) { + setAuthError(true); + } + } finally { + setLoading(false); + } + }; + + const formatNumber = (num: number) => { + return new Intl.NumberFormat('en-US').format(num); + }; + + const formatCurrency = (num: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 4 + }).format(num); + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('es-ES', { + day: '2-digit', + month: 'short' + }); + }; + + // Calculate max value for chart scaling + const maxDailyTokens = data?.daily_usage.reduce((max, d) => Math.max(max, d.total_tokens), 0) || 1; + + if (loading) { + return ( +
+
+
+

Cargando métricas globales...

+
+
+ ); + } + + if (authError) { + return ( +
+
+
+

+ Error de Autenticación +

+

+ No se pudo cargar los datos. Esto puede deberse a: +

+
    +
  • Tu sesión ha expirado
  • +
  • No has iniciado sesión
  • +
  • El token no es válido
  • +
+
+
+ + + Iniciar Sesión + +
+

+ Token en localStorage: {localStorage.getItem('studio_token') ? '✓ Existe' : '✗ No existe'} +

+
+
+ ); + } + + if (!data) { + return ( +
+
+

Error al cargar los datos. Por favor, recarga la página.

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+
+ +
+

+ Control Global - Uso de IA +

+

+ Monitoreo integral de tokens, costos y ahorro en todo el sistema +

+
+
+ + {/* Date Range Selector */} +
+ {(['7d', '30d', '90d'] as const).map((range) => ( + + ))} +
+
+
+
+ +
+ {/* Summary Cards */} +
+ {/* Total Tokens */} +
+
+
+ +
+ +
+

Total Tokens

+

+ {formatNumber(data.summary.total_tokens)} +

+
+ Input: {formatNumber(data.summary.total_input)} | Output: {formatNumber(data.summary.total_output)} +
+
+ + {/* Costo Total */} +
+
+
+ +
+ +
+

Costo Total (IA Local)

+

+ {formatCurrency(data.summary.total_cost_usd)} +

+
+ Requests: {formatNumber(data.summary.total_requests)} +
+
+ + {/* Ahorro vs OpenAI */} +
+
+
+ +
+ +
+

Ahorro vs OpenAI GPT-4

+

+ {formatCurrency(data.summary.savings_vs_openai_usd)} +

+
+ {data.summary.savings_percentage.toFixed(1)}% más económico +
+
+ + {/* Usuarios Activos */} +
+
+
+ +
+ +
+

Usuarios Activos

+

+ {data.summary.total_active_users} +

+
+ Organizaciones: {data.summary.total_organizations} +
+
+
+ + {/* Student Chat Summary */} + {data.student_chat_summary && ( +
+
+ +

+ Uso del Chat por Alumnos +

+
+
+
+

Tokens Consumidos

+

+ {formatNumber(data.student_chat_summary.total_tokens)} +

+
+
+

Requests de Chat

+

+ {formatNumber(data.student_chat_summary.total_requests)} +

+
+
+

Costo Total

+

+ {formatCurrency(data.student_chat_summary.total_cost_usd)} +

+
+
+

Alumnos Activos

+

+ {data.student_chat_summary.active_students} +

+
+
+
+ )} + + {/* Charts Row */} +
+ {/* Daily Usage Chart */} +
+
+ +

Uso Diario de Tokens

+
+
+ {data.daily_usage.slice(-30).map((day, idx) => ( +
+
+ + {formatDate(day.date)} + +
+ ))} +
+
+ + {/* Usage by Request Type */} +
+
+ +

Uso por Tipo de Request

+
+
+ {data.by_request_type.map((type, idx) => { + const percentage = data.summary.total_tokens > 0 + ? (type.total_tokens / data.summary.total_tokens) * 100 + : 0; + const colors = [ + 'bg-blue-500', + 'bg-green-500', + 'bg-purple-500', + 'bg-orange-500', + 'bg-pink-500' + ]; + return ( +
+
+ + {type.request_type} + + + {formatNumber(type.total_tokens)} tokens ({percentage.toFixed(1)}%) + +
+
+
+
+
+ ); + })} +
+
+
+ + {/* Usage by Organization */} +
+
+ +

Uso por Organización

+
+
+ + + + + + + + + + + + {data.by_organization.map((org) => ( + + + + + + + + ))} + +
OrganizaciónTokensRequestsUsuarios ActivosCosto USD
{org.org_name}{formatNumber(org.total_tokens)}{formatNumber(org.requests)}{org.active_users}{formatCurrency(org.cost_usd)}
+
+
+ + {/* Top Users */} +
+
+ +

Top 20 Usuarios por Consumo

+
+
+ + + + + + + + + + + + + {data.top_users.map((user, idx) => ( + + + + + + + + + ))} + +
UsuarioOrganizaciónRolTokensRequestsCosto USD
+
+
{user.full_name}
+
{user.email}
+
+
{user.org_name} + + {user.role} + + {formatNumber(user.total_tokens)}{formatNumber(user.requests)}{formatCurrency(user.cost_usd)}
+
+
+ + {/* Student Chat Usage Detail */} + {data.student_chat_usage.length > 0 && ( +
+
+ +

+ Detalle de Uso del Chat por Alumnos (Top 50) +

+
+
+ + + + + + + + + + + + + {data.student_chat_usage.map((student) => ( + + + + + + + + + ))} + +
AlumnoOrganizaciónTokensRequestsCosto USDÚltimo Chat
+
+
{student.full_name}
+
{student.email}
+
+
{student.org_name}{formatNumber(student.total_tokens)}{formatNumber(student.chat_requests)}{formatCurrency(student.cost_usd)} + {new Date(student.last_chat).toLocaleDateString('es-ES')} +
+
+
+ )} +
+
+ ); +} diff --git a/web/studio/src/app/admin/layout.tsx b/web/studio/src/app/admin/layout.tsx index e753f61..065ae1e 100644 --- a/web/studio/src/app/admin/layout.tsx +++ b/web/studio/src/app/admin/layout.tsx @@ -10,7 +10,8 @@ import { ClipboardList, ShieldCheck, ArrowLeft, - Activity + Activity, + TrendingUp } from "lucide-react"; export default function AdminLayout({ children }: { children: React.ReactNode }) { @@ -22,6 +23,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { icon: Users, label: "Users", href: "/admin/users" }, { icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" }, { icon: Activity, label: "System Tasks", href: "/admin/tasks" }, + { icon: TrendingUp, label: "Control Global IA", href: "/admin/ai-usage-global" }, ]; return ( diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 50aa4aa..8dde9d2 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -233,6 +233,102 @@ export interface UploadResponse { favicon_url?: string; } +// ==================== AI Usage Global ==================== + +export interface GlobalAiSummary { + total_tokens: number; + total_input: number; + total_output: number; + total_requests: number; + total_cost_usd: number; + savings_vs_openai_usd: number; + savings_percentage: number; + openai_equivalent_cost_usd: number; + total_organizations: number; + total_active_users: number; +} + +export interface StudentChatSummary { + total_tokens: number; + total_requests: number; + total_cost_usd: number; + active_students: number; +} + +export interface DailyUsage { + date: string; + total_tokens: number; + input_tokens: number; + output_tokens: number; + cost_usd: number; + requests: number; +} + +export interface UsageByEndpoint { + endpoint: string; + request_type: string; + total_tokens: number; + input_tokens: number; + output_tokens: number; + cost_usd: number; + requests: number; +} + +export interface UsageByOrganization { + org_id: string; + org_name: string; + total_tokens: number; + input_tokens: number; + output_tokens: number; + cost_usd: number; + requests: number; + active_users: number; +} + +export interface UsageByRequestType { + request_type: string; + total_tokens: number; + cost_usd: number; + requests: number; +} + +export interface TopUserUsage { + user_id: string; + email: string; + full_name: string; + role: string; + org_name: string; + total_tokens: number; + input_tokens: number; + output_tokens: number; + cost_usd: number; + requests: number; +} + +export interface StudentChatUsage { + user_id: string; + email: string; + full_name: string; + org_name: string; + total_tokens: number; + cost_usd: number; + chat_requests: number; + last_chat: string; +} + +export interface GlobalAiUsageResponse { + summary: GlobalAiSummary; + student_chat_summary: StudentChatSummary | null; + daily_usage: DailyUsage[]; + by_endpoint: UsageByEndpoint[]; + by_organization: UsageByOrganization[]; + by_request_type: UsageByRequestType[]; + top_users: TopUserUsage[]; + student_chat_usage: StudentChatUsage[]; +} + +// ==================== Grading ==================== + export interface GradingCategory { id: string; course_id: string; @@ -926,6 +1022,10 @@ export const cmsApi = { apiFetch(`/test-templates/${templateId}/apply`, { method: 'POST', body: JSON.stringify({ lesson_id: lessonId, grading_category_id: gradingCategoryId }) }, false), generateQuestionsWithRAG: (courseId?: number, topic?: string, numQuestions?: number): Promise => apiFetch('/test-templates/generate-with-rag', { method: 'POST', body: JSON.stringify({ course_id: courseId, topic, num_questions: numQuestions }) }, false), + + // Admin - AI Usage Global + getGlobalAiUsage: (startDate?: string, endDate?: string): Promise => + apiFetch('/admin/ai-usage/global', { query: { start_date: startDate, end_date: endDate } }, false), }; // ==================== Question Bank ====================