Fix AI question parsing and expose token admin

This commit is contained in:
2026-04-08 14:48:45 -04:00
parent 95d5dc9e3e
commit 6f340f14df
4 changed files with 142 additions and 40 deletions
@@ -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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> = 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 (
+3 -1
View File
@@ -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" },
+4 -1
View File
@@ -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() {
<Link href="/admin/tasks" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Tasks">
<Activity size={16} /> <span className="hidden md:inline">Tasks</span>
</Link>
<Link href="/admin/token-usage" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Tokens IA">
<Gauge size={16} /> <span className="hidden md:inline">Tokens</span>
</Link>
<Link href="/settings" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Settings">
<Settings size={16} /> <span className="hidden md:inline">Settings</span>
</Link>
@@ -118,6 +118,38 @@ export default function QuestionBankCard({ question, onEdit, onDelete }: Questio
{safeQuestionText as any}
</p>
{/* Matching Pairs Preview */}
{question.question_type === 'matching' && (
<div className="mb-3 space-y-2">
{(() => {
// 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 <div className="text-xs text-gray-400">Sin pares definidos</div>;
}
return pairs.slice(0, 3).map((pair: any, idx: number) => (
<div key={idx} className="text-xs bg-gray-50 dark:bg-gray-700/50 p-2 rounded flex items-center gap-2">
<span className="flex-1 text-gray-700 dark:text-gray-300">{pair.left || pair[0]}</span>
<span className="text-gray-400"></span>
<span className="flex-1 text-gray-600 dark:text-gray-400 line-clamp-1">{pair.right || pair[1]}</span>
</div>
));
})()}
{(() => {
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 ? (
<div className="text-xs text-gray-400">+{pairs.length - 3} pares más</div>
) : null;
})()}
</div>
)}
{/* Audio Player */}
{question.audio_url && (
<div className="mb-3">