feat: SAM integration, deployment scripts, and audio response enhancements

- Add SAM (Sistema de Administración Académica) integration with sync endpoints
- Add deployment automation (deploy.sh, remote-setup.sh, setup-nginx-ssl.sh)
- Add nginx proxy configuration for SSL with Let's Encrypt
- Add audio response support for student lessons (migrations, handlers)
- Add audio evaluations admin page
- Update CORS to support wildcard subdomains for norteamericano.cl
- Add comprehensive deployment documentation (DESPLIEGUE.md, ManualDeConfiguracion.md)
- Update docker-compose.yml with nginx-proxy and acme-companion services
- Remove outdated documentation files

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-27 09:20:23 -03:00
parent 995065df4f
commit e4866c6dee
50 changed files with 4866 additions and 5371 deletions
+2
View File
@@ -20,7 +20,9 @@ COPY web/studio/package*.json ./
RUN npm ci
COPY web/studio/ .
ARG NEXT_PUBLIC_CMS_API_URL
ARG NEXT_PUBLIC_LMS_API_URL
ENV NEXT_PUBLIC_CMS_API_URL=$NEXT_PUBLIC_CMS_API_URL
ENV NEXT_PUBLIC_LMS_API_URL=$NEXT_PUBLIC_LMS_API_URL
RUN npm run build
# Final stage
@@ -0,0 +1,406 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import {
Mic,
Play,
Pause,
CheckCircle,
AlertCircle,
BrainCircuit,
User,
Calendar,
BookOpen,
Filter,
Download,
RefreshCcw
} from "lucide-react";
import { lmsApi, type AudioResponse, type AudioResponseFilters } from "@/lib/api";
import PageLayout from "@/components/PageLayout";
import AuthGuard from "@/components/AuthGuard";
export default function AudioEvaluationsPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [evaluations, setEvaluations] = useState<AudioResponse[]>([]);
const [selectedEvaluation, setSelectedEvaluation] = useState<AudioResponse | null>(null);
const [teacherScore, setTeacherScore] = useState<number>(50);
const [teacherFeedback, setTeacherFeedback] = useState<string>("");
const [filters, setFilters] = useState<AudioResponseFilters>({});
const [playingId, setPlayingId] = useState<string | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const fetchEvaluations = async () => {
setLoading(true);
try {
const data = await lmsApi.getAudioResponses(filters);
setEvaluations(data);
} catch (error) {
console.error("Error fetching audio evaluations:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEvaluations();
}, [filters]);
const handlePlayAudio = async (id: string) => {
if (playingId === id) {
// Stop playing
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
setPlayingId(null);
setAudioUrl(null);
} else {
try {
const audioBlob = await lmsApi.getAudioResponseAudio(id);
const url = URL.createObjectURL(audioBlob);
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
setAudioUrl(url);
setPlayingId(id);
const audio = new Audio(url);
audio.onended = () => {
setPlayingId(null);
setAudioUrl(null);
};
audio.play();
} catch (error) {
console.error("Error playing audio:", error);
alert("Error al reproducir el audio");
}
}
};
const handleSubmitEvaluation = async () => {
if (!selectedEvaluation) return;
try {
await lmsApi.evaluateAudioResponse(
selectedEvaluation.id,
teacherScore,
teacherFeedback
);
alert("Evaluación guardada exitosamente");
setSelectedEvaluation(null);
fetchEvaluations();
} catch (error) {
console.error("Error submitting evaluation:", error);
alert("Error al guardar la evaluación");
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
return <span className="px-3 py-1 bg-yellow-500/10 border border-yellow-500/20 rounded-full text-xs font-bold text-yellow-600 dark:text-yellow-400">Pendiente</span>;
case 'ai_evaluated':
return <span className="px-3 py-1 bg-blue-500/10 border border-blue-500/20 rounded-full text-xs font-bold text-blue-600 dark:text-blue-400">IA Evaluada</span>;
case 'teacher_evaluated':
return <span className="px-3 py-1 bg-purple-500/10 border border-purple-500/20 rounded-full text-xs font-bold text-purple-600 dark:text-purple-400">Profesor Evaluó</span>;
case 'both_evaluated':
return <span className="px-3 py-1 bg-green-500/10 border border-green-500/20 rounded-full text-xs font-bold text-green-600 dark:text-green-400">Completa</span>;
default:
return null;
}
};
const getScoreColor = (score: number | null) => {
if (score === null) return 'text-gray-400';
if (score >= 70) return 'text-green-600 dark:text-green-400';
if (score >= 40) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
};
return (
<PageLayout title="Evaluaciones de Audio">
<div className="space-y-6">
{/* Filters */}
<div className="p-6 glass border-black/5 dark:border-white/5 rounded-3xl bg-black/[0.02] dark:bg-black/20">
<div className="flex items-center gap-3 mb-4">
<Filter className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Filtros</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Estado</label>
<select
value={filters.status || ''}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined })}
className="w-full mt-1 px-4 py-2 bg-white dark:bg-black/40 border border-gray-200 dark:border-white/10 rounded-xl text-sm font-medium outline-none focus:ring-2 focus:ring-purple-500/20"
>
<option value="">Todos</option>
<option value="pending">Pendiente</option>
<option value="ai_evaluated">Solo IA</option>
<option value="teacher_evaluated">Solo Profesor</option>
<option value="both_evaluated">Completa</option>
</select>
</div>
<div>
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Curso ID</label>
<input
type="text"
value={filters.course_id || ''}
onChange={(e) => setFilters({ ...filters, course_id: e.target.value || undefined })}
placeholder="UUID del curso"
className="w-full mt-1 px-4 py-2 bg-white dark:bg-black/40 border border-gray-200 dark:border-white/10 rounded-xl text-sm font-medium outline-none focus:ring-2 focus:ring-purple-500/20"
/>
</div>
<div>
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Lección ID</label>
<input
type="text"
value={filters.lesson_id || ''}
onChange={(e) => setFilters({ ...filters, lesson_id: e.target.value || undefined })}
placeholder="UUID de la lección"
className="w-full mt-1 px-4 py-2 bg-white dark:bg-black/40 border border-gray-200 dark:border-white/10 rounded-xl text-sm font-medium outline-none focus:ring-2 focus:ring-purple-500/20"
/>
</div>
</div>
<button
onClick={() => {
setFilters({});
fetchEvaluations();
}}
className="mt-4 flex items-center gap-2 px-4 py-2 glass hover:bg-black/5 dark:hover:bg-white/10 rounded-xl text-sm font-bold text-purple-600 dark:text-purple-400 transition-all"
>
<RefreshCcw className="w-4 h-4" />
Limpiar filtros
</button>
</div>
{/* Evaluations Table */}
<div className="glass border-black/5 dark:border-white/5 rounded-3xl bg-black/[0.02] dark:bg-black/20 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-purple-600/10 to-pink-600/10 border-b border-black/5 dark:border-white/5">
<tr>
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Estudiante</th>
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Curso / Lección</th>
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Prompt</th>
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Transcripción</th>
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">IA</th>
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Profesor</th>
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Estado</th>
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Fecha</th>
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-black/5 dark:divide-white/5">
{loading ? (
<tr>
<td colSpan={9} className="px-6 py-12 text-center">
<div className="flex items-center justify-center gap-3 text-gray-400">
<RefreshCcw className="w-6 h-6 animate-spin" />
<span className="text-sm font-medium">Cargando evaluaciones...</span>
</div>
</td>
</tr>
) : evaluations.length === 0 ? (
<tr>
<td colSpan={9} className="px-6 py-12 text-center text-gray-400">
<Mic className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p className="text-sm font-medium">No hay evaluaciones de audio</p>
</td>
</tr>
) : (
evaluations.map((eval_) => (
<tr key={eval_.id} className="hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
{eval_.student_name.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm font-bold text-gray-900 dark:text-gray-100">{eval_.student_name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{eval_.student_email}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="max-w-[200px]">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" title={eval_.course_title}>{eval_.course_title}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate" title={eval_.lesson_title}>{eval_.lesson_title}</p>
</div>
</td>
<td className="px-6 py-4">
<p className="text-sm text-gray-700 dark:text-gray-300 max-w-[200px] truncate" title={eval_.prompt}>{eval_.prompt}</p>
</td>
<td className="px-6 py-4">
<p className="text-sm text-gray-700 dark:text-gray-300 max-w-[200px] truncate" title={eval_.transcript || ''}>
{eval_.transcript || <span className="text-gray-400 italic">Sin transcripción</span>}
</p>
</td>
<td className="px-6 py-4">
{eval_.ai_score !== null ? (
<div className="space-y-1">
<p className={`text-lg font-black ${getScoreColor(eval_.ai_score)}`}>{eval_.ai_score}%</p>
{eval_.ai_found_keywords && eval_.ai_found_keywords.length > 0 && (
<p className="text-xs text-gray-500 dark:text-gray-400">
Keywords: {eval_.ai_found_keywords.join(', ')}
</p>
)}
</div>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-6 py-4">
{eval_.teacher_score !== null ? (
<p className={`text-lg font-black ${getScoreColor(eval_.teacher_score)}`}>{eval_.teacher_score}%</p>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-6 py-4">
{getStatusBadge(eval_.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<Calendar className="w-3 h-3" />
{new Date(eval_.created_at).toLocaleDateString()}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
onClick={() => handlePlayAudio(eval_.id)}
className="p-2 glass hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-all"
title="Reproducir audio"
>
{playingId === eval_.id ? (
<Pause className="w-4 h-4 text-purple-600 dark:text-purple-400" />
) : (
<Play className="w-4 h-4 text-purple-600 dark:text-purple-400" />
)}
</button>
<button
onClick={() => setSelectedEvaluation(eval_)}
className="p-2 glass hover:bg-purple-500/10 rounded-lg transition-all"
title="Evaluar"
>
<BrainCircuit className="w-4 h-4 text-purple-600 dark:text-purple-400" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Evaluation Modal */}
{selectedEvaluation && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass border-black/5 dark:border-white/5 rounded-3xl bg-white dark:bg-gray-900 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-black text-gray-900 dark:text-white">Evaluar Respuesta de Audio</h3>
<button
onClick={() => setSelectedEvaluation(null)}
className="p-2 glass hover:bg-black/5 dark:hover:bg-white/10 rounded-xl transition-all"
>
<AlertCircle className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
</div>
{/* Student Info */}
<div className="p-4 bg-gradient-to-r from-purple-500/10 to-pink-500/10 rounded-2xl border border-purple-500/20">
<div className="flex items-center gap-3 mb-2">
<User className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<p className="text-sm font-bold text-gray-900 dark:text-gray-100">{selectedEvaluation.student_name}</p>
</div>
<div className="flex items-center gap-3">
<BookOpen className="w-4 h-4 text-gray-500" />
<p className="text-xs text-gray-600 dark:text-gray-400">{selectedEvaluation.course_title} {selectedEvaluation.lesson_title}</p>
</div>
</div>
{/* Prompt */}
<div>
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Prompt</label>
<p className="mt-1 text-lg font-medium text-gray-900 dark:text-gray-100">{selectedEvaluation.prompt}</p>
</div>
{/* Transcript */}
<div>
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Transcripción</label>
<p className="mt-1 p-4 bg-black/5 dark:bg-white/5 rounded-xl text-gray-700 dark:text-gray-300">
{selectedEvaluation.transcript || <span className="italic text-gray-400">Sin transcripción disponible</span>}
</p>
</div>
{/* AI Evaluation */}
{selectedEvaluation.ai_score !== null && (
<div className="p-4 bg-blue-500/10 border border-blue-500/20 rounded-2xl space-y-3">
<div className="flex items-center gap-2">
<BrainCircuit className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<label className="text-xs font-bold text-blue-600 dark:text-blue-400 uppercase tracking-wider">Evaluación de IA</label>
</div>
<div className="flex items-center gap-4">
<p className="text-3xl font-black text-blue-600 dark:text-blue-400">{selectedEvaluation.ai_score}%</p>
</div>
{selectedEvaluation.ai_feedback && (
<p className="text-sm text-gray-700 dark:text-gray-300 italic">"{selectedEvaluation.ai_feedback}"</p>
)}
</div>
)}
{/* Teacher Evaluation */}
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Puntuación (0-100)</label>
<input
type="number"
min="0"
max="100"
value={teacherScore}
onChange={(e) => setTeacherScore(parseInt(e.target.value))}
className="w-full mt-1 px-4 py-3 bg-white dark:bg-black/40 border border-gray-200 dark:border-white/10 rounded-xl text-lg font-bold outline-none focus:ring-2 focus:ring-purple-500/20"
/>
</div>
<div>
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Feedback</label>
<textarea
value={teacherFeedback}
onChange={(e) => setTeacherFeedback(e.target.value)}
placeholder="Escribe tu feedback para el estudiante..."
rows={4}
className="w-full mt-1 px-4 py-3 bg-white dark:bg-black/40 border border-gray-200 dark:border-white/10 rounded-xl text-sm font-medium outline-none focus:ring-2 focus:ring-purple-500/20 resize-none"
/>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
onClick={() => setSelectedEvaluation(null)}
className="flex-1 py-3 glass hover:bg-black/5 dark:hover:bg-white/10 rounded-xl font-bold text-gray-600 dark:text-gray-300 transition-all"
>
Cancelar
</button>
<button
onClick={handleSubmitEvaluation}
className="flex-1 py-3 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 rounded-xl font-bold text-white shadow-lg shadow-purple-500/30 transition-all"
>
Guardar Evaluación
</button>
</div>
</div>
</div>
</div>
)}
</div>
</PageLayout>
);
}
+3 -1
View File
@@ -11,7 +11,8 @@ import {
ShieldCheck,
ArrowLeft,
Activity,
TrendingUp
TrendingUp,
Mic
} from "lucide-react";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
@@ -21,6 +22,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{ icon: LayoutDashboard, label: "Dashboard", href: "/admin" },
{ icon: Building2, label: "Organizations", href: "/admin/organizations" },
{ icon: Users, label: "Users", href: "/admin/users" },
{ icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" },
{ 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" },
+133 -3
View File
@@ -1,11 +1,17 @@
const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
// Si hay una variable de entorno definida, usarla siempre
if (envVar && envVar.trim() !== '') {
return envVar;
}
// Fallback para desarrollo local
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
// Detect if we are on a custom domain or IP
const protocol = window.location.protocol;
return `${protocol}//${hostname}:${defaultPort}`;
}
return envVar || `http://localhost:${defaultPort}`;
return `http://localhost:${defaultPort}`;
};
export const API_BASE_URL = getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
@@ -763,7 +769,6 @@ export const cmsApi = {
},
getSSOConfig: (): Promise<OrganizationSSOConfig> => apiFetch('/organization/sso'),
updateSSOConfig: (payload: Partial<OrganizationSSOConfig>): Promise<void> => apiFetch('/organization/sso', { method: 'PUT', body: JSON.stringify(payload) }),
getOrganization: (): Promise<Organization> => apiFetch('/organization'),
// Auth
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
@@ -1209,6 +1214,37 @@ export const lmsApi = {
getMyBadges: (): Promise<Badge[]> =>
apiFetch(`/my/badges`, {}, true),
// Audio Responses
getAudioResponses: (filters?: AudioResponseFilters): Promise<AudioResponse[]> => {
const query: Record<string, string> = {};
if (filters?.course_id) query.course_id = filters.course_id;
if (filters?.lesson_id) query.lesson_id = filters.lesson_id;
if (filters?.status) query.status = filters.status;
if (filters?.user_id) query.user_id = filters.user_id;
return apiFetch('/audio-responses', { method: 'GET', query }, true);
},
getAudioResponseDetail: (id: string): Promise<AudioResponse> =>
apiFetch(`/audio-responses/${id}`, { method: 'GET' }, true),
getAudioResponseAudio: (id: string): Promise<Blob> => {
const token = getToken();
return fetch(`${LMS_API_BASE_URL}/audio-responses/${id}/audio`, {
method: 'GET',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
}).then(async res => {
if (!res.ok) throw new Error('Failed to fetch audio');
return res.blob();
});
},
evaluateAudioResponse: (id: string, teacherScore: number, teacherFeedback?: string): Promise<{ success: boolean; message: string }> =>
apiFetch(`/audio-responses/${id}/evaluate`, {
method: 'POST',
body: JSON.stringify({ teacher_score: teacherScore, teacher_feedback: teacherFeedback })
}, true),
getCourseAudioResponseStats: (courseId: string): Promise<AudioResponseStats> =>
apiFetch(`/courses/${courseId}/audio-responses/stats`, { method: 'GET' }, true),
};
export interface Meeting {
@@ -1384,4 +1420,98 @@ export interface TestTemplateFilters {
test_type?: TestType;
tags?: string;
search?: string;
}
// ==================== AUDIO RESPONSE INTERFACES ====================
export interface AudioResponse {
id: string;
user_id: string;
student_name: string;
student_email: string;
course_id: string;
course_title: string;
lesson_id: string;
lesson_title: string;
block_id: string;
prompt: string;
transcript: string | null;
ai_score: number | null;
ai_found_keywords: string[] | null;
ai_feedback: string | null;
teacher_score: number | null;
teacher_feedback: string | null;
status: 'pending' | 'ai_evaluated' | 'teacher_evaluated' | 'both_evaluated';
created_at: string;
attempt_number: number;
}
export interface AudioResponseStats {
organization_id: string;
course_id: string;
lesson_id: string;
total_responses: number;
ai_evaluated: number;
teacher_evaluated: number;
fully_evaluated: number;
pending: number;
avg_ai_score: number | null;
avg_teacher_score: number | null;
}
export interface AudioResponseFilters {
course_id?: string;
lesson_id?: string;
status?: string;
user_id?: string;
}
// ==================== AUDIO RESPONSE API ====================
export async function getAudioResponses(filters?: AudioResponseFilters): Promise<AudioResponse[]> {
const query: Record<string, string> = {};
if (filters?.course_id) query.course_id = filters.course_id;
if (filters?.lesson_id) query.lesson_id = filters.lesson_id;
if (filters?.status) query.status = filters.status;
if (filters?.user_id) query.user_id = filters.user_id;
return apiFetch('/audio-responses', { method: 'GET', query }, true);
}
export async function getAudioResponseDetail(id: string): Promise<AudioResponse> {
return apiFetch(`/audio-responses/${id}`, { method: 'GET' }, true);
}
export async function getAudioResponseAudio(id: string): Promise<Blob> {
const token = getToken();
const response = await fetch(`${LMS_API_BASE_URL}/audio-responses/${id}/audio`, {
method: 'GET',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
if (!response.ok) {
throw new Error('Failed to fetch audio');
}
return response.blob();
}
export async function evaluateAudioResponse(
id: string,
teacherScore: number,
teacherFeedback?: string
): Promise<{ success: boolean; message: string }> {
return apiFetch(`/audio-responses/${id}/evaluate`, {
method: 'POST',
body: JSON.stringify({
teacher_score: teacherScore,
teacher_feedback: teacherFeedback
})
}, true);
}
export async function getCourseAudioResponseStats(courseId: string): Promise<AudioResponseStats> {
return apiFetch(`/courses/${courseId}/audio-responses/stats`, { method: 'GET' }, true);
}
File diff suppressed because one or more lines are too long