Fix AI question parsing and expose token admin
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user