diff --git a/services/cms-service/src/handlers_test_templates.rs b/services/cms-service/src/handlers_test_templates.rs index 478965e..3bab76e 100644 --- a/services/cms-service/src/handlers_test_templates.rs +++ b/services/cms-service/src/handlers_test_templates.rs @@ -696,6 +696,33 @@ Rules: rag_context, num_questions, topic ) } + "essay" => { + format!( + r#"You are an English Teacher creating essay questions. + +Use these examples as inspiration (do NOT copy): +{} + +Create {} ORIGINAL essay questions about: {} + +IMPORTANT - Return ONLY a JSON array with this EXACT structure: +[ + {{ + "question_text": "Explain how setting influences the mood of a short story.", + "question_type": "essay", + "correct_answer": "A strong response explains specific setting details and connects them clearly to mood with evidence.", + "explanation": "This assesses analytical writing and use of textual evidence.", + "points": 3 + }} +] + +Rules: +- Questions must require extended written responses +- correct_answer should contain rubric guidance or expected criteria +- No options array is needed"#, + rag_context, num_questions, topic + ) + } "matching" => { format!( r#"You are an English Teacher creating quiz questions. @@ -848,47 +875,85 @@ Rules: } } +// Flatten any value into a list of question objects. +// Handles: plain array, {"questions":[...]}, {"key":[...q...]}, single object with question_text or type-specific fields. +fn flatten_into_questions(v: &serde_json::Value) -> Vec { + if let Some(arr) = v.as_array() { + // Could be [[q1,q2],[q3]] if LLM wrapped questions in sub-arrays — flatten one level. + let mut out = Vec::new(); + for item in arr { + if item.is_object() { + out.push(item.clone()); + } else if let Some(inner) = item.as_array() { + for q in inner { + if q.is_object() { + out.push(q.clone()); + } + } + } + } + return out; + } + if let Some(wrapped) = v.get("questions") { + return flatten_into_questions(wrapped); + } + if let Some(items) = v.get("items") { + // Only treat "items" as a wrapper when it contains question objects/arrays. + // Ordering questions also use "items", but there it is an array of strings. + let looks_like_question_wrapper = items + .as_array() + .map(|arr| arr.iter().all(|item| item.is_object() || item.is_array())) + .unwrap_or(false); + if looks_like_question_wrapper { + return flatten_into_questions(items); + } + } + if v.as_object().is_some() { + // Check for type-specific fields that indicate this is a single question + let has_question_text = v.get("question_text").is_some(); + let has_ordering_fields = v.get("items").is_some() || v.get("correct_order").is_some(); + let has_matching_fields = v.get("pairs").is_some(); + let has_blanks_fields = v.get("blanks").is_some(); + let has_options = v.get("options").is_some(); + let has_correct_answer = v.get("correct_answer").is_some() || v.get("correct").is_some(); + + // If this looks like a single question (has question_text or type-specific fields), return it + if has_question_text || has_ordering_fields || has_matching_fields || has_blanks_fields || + (has_options && (has_correct_answer || v.get("question_type").is_some())) { + return vec![v.clone()]; + } + + // Otherwise, it might be {"question1": [...], "question2": [...]} — flatten all value arrays/objects + let obj = v.as_object().unwrap(); + let mut out = Vec::new(); + for val in obj.values() { + if val.is_object() && + (val.get("question_text").is_some() || + val.get("items").is_some() || + val.get("pairs").is_some() || + val.get("blanks").is_some() || + val.get("options").is_some()) { + // Single question stored as a key's value + out.push(val.clone()); + } else if let Some(arr) = val.as_array() { + for q in arr { + if q.is_object() { + out.push(q.clone()); + } + } + } + } + return out; + } + vec![] +} + // Helper function to parse AI response based on question type fn parse_ai_response_for_question_type( questions_data: &serde_json::Value, - question_type: &str, + _question_type: &str, ) -> Vec { - match question_type { - "true-false" | "short-answer" | "matching" | "ordering" | "fill-in-the-blanks" => { - // These types should return an array of question objects - if let Some(arr) = questions_data.as_array() { - arr.clone() - } else if let Some(wrapped) = questions_data - .get("questions") - .or(questions_data.get("items")) - { - wrapped.as_array().cloned().unwrap_or_default() - } else if let Some(obj) = questions_data.as_object() { - obj.values().cloned().collect() - } else { - vec![] - } - } - _ => { - // Default parsing for multiple-choice and others - if let Some(arr) = questions_data.as_array() { - arr.clone() - } else if let Some(questions) = questions_data - .get("questions") - .or(questions_data.get("items")) - { - questions.as_array().cloned().unwrap_or_default() - } else if let Some(obj) = questions_data.as_object() { - let questions: Vec = obj.values().cloned().collect(); - if !questions.is_empty() { - return questions; - } - vec![] - } else { - vec![] - } - } - } + flatten_into_questions(questions_data) } /// POST /test-templates/generate-with-rag - Generate questions using RAG from imported MySQL question bank @@ -940,7 +1005,7 @@ pub async fn generate_questions_with_rag( (qb.source_metadata->>'nivel_curso')::integer, NULL ) as nivel_curso, - 1 - (qb.embedding <=> $1::vector) AS similarity + (1 - (qb.embedding <=> $1::vector))::float4 AS similarity FROM question_bank qb WHERE qb.organization_id = $2 AND ( diff --git a/web/studio/src/app/admin/layout.tsx b/web/studio/src/app/admin/layout.tsx index e42ceb4..d7a50a2 100644 --- a/web/studio/src/app/admin/layout.tsx +++ b/web/studio/src/app/admin/layout.tsx @@ -13,7 +13,8 @@ import { Activity, TrendingUp, Mic, - FileArchive + FileArchive, + Gauge } from "lucide-react"; export default function AdminLayout({ children }: { children: React.ReactNode }) { @@ -23,6 +24,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { icon: LayoutDashboard, label: "Dashboard", href: "/admin" }, { icon: Building2, label: "Organizations", href: "/admin" }, { icon: Users, label: "Users", href: "/admin/users" }, + { icon: Gauge, label: "Tokens IA", href: "/admin/token-usage" }, { icon: FileArchive, label: "Material Compartido", href: "/admin/materials" }, { icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" }, { icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" }, diff --git a/web/studio/src/components/AuthHeader.tsx b/web/studio/src/components/AuthHeader.tsx index 23a3b8e..0ca36ba 100644 --- a/web/studio/src/components/AuthHeader.tsx +++ b/web/studio/src/components/AuthHeader.tsx @@ -1,7 +1,7 @@ "use client"; import { useAuth } from "@/context/AuthContext"; -import { LogOut, ShieldAlert, Building2, Activity, Settings } from "lucide-react"; +import { LogOut, ShieldAlert, Building2, Activity, Settings, Gauge } from "lucide-react"; import Link from "next/link"; export default function AuthHeader() { @@ -19,6 +19,9 @@ export default function AuthHeader() { Tasks + + Tokens + Settings diff --git a/web/studio/src/components/QuestionBank/QuestionBankCard.tsx b/web/studio/src/components/QuestionBank/QuestionBankCard.tsx index 92b25bd..55d596a 100644 --- a/web/studio/src/components/QuestionBank/QuestionBankCard.tsx +++ b/web/studio/src/components/QuestionBank/QuestionBankCard.tsx @@ -118,6 +118,38 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio {safeQuestionText as any}

+ {/* Matching Pairs Preview */} + {question.question_type === 'matching' && ( +
+ {(() => { + // Try to get pairs from different possible locations + let pairs = question.pairs || + (question.correct_answer && Array.isArray(question.correct_answer) ? question.correct_answer : null) || + (question.correct_answer && question.correct_answer.pairs ? question.correct_answer.pairs : null); + + if (!Array.isArray(pairs) || pairs.length === 0) { + return
Sin pares definidos
; + } + + return pairs.slice(0, 3).map((pair: any, idx: number) => ( +
+ {pair.left || pair[0]} + + {pair.right || pair[1]} +
+ )); + })()} + {(() => { + let pairs = question.pairs || + (question.correct_answer && Array.isArray(question.correct_answer) ? question.correct_answer : null) || + (question.correct_answer && question.correct_answer.pairs ? question.correct_answer.pairs : null); + return Array.isArray(pairs) && pairs.length > 3 ? ( +
+{pairs.length - 3} pares más
+ ) : null; + })()} +
+ )} + {/* Audio Player */} {question.audio_url && (