From 6f340f14df670df46553196fa1a2b7d7e9c46ac5 Mon Sep 17 00:00:00 2001
From: Nurfog
Date: Wed, 8 Apr 2026 14:48:45 -0400
Subject: [PATCH] Fix AI question parsing and expose token admin
---
.../src/handlers_test_templates.rs | 141 +++++++++++++-----
web/studio/src/app/admin/layout.tsx | 4 +-
web/studio/src/components/AuthHeader.tsx | 5 +-
.../QuestionBank/QuestionBankCard.tsx | 32 ++++
4 files changed, 142 insertions(+), 40 deletions(-)
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 && (