Refactor audio handling and S3 integration in LMS service

- Removed company-specific template rules from template application logic.
- Enhanced question generation queries to support both 'imported-mysql' and 'imported-material' sources.
- Introduced S3 audio storage functionality, including client setup and audio key generation.
- Updated audio response evaluation to store audio files in S3 or fallback to DB.
- Added new API routes for asset ingestion and ZIP import in CMS service.
- Implemented role-based access control for audio responses in LMS service.
- Created a smoke test script for validating audio roles and permissions.
- Updated frontend to support course selection in audio evaluations.
This commit is contained in:
2026-04-06 09:11:56 -04:00
parent 4afccb89ef
commit 516a903497
12 changed files with 2476 additions and 166 deletions
@@ -16,18 +16,21 @@ import {
Download,
RefreshCcw
} from "lucide-react";
import { lmsApi, type AudioResponse, type AudioResponseFilters } from "@/lib/api";
import { cmsApi, lmsApi, type AudioResponse, type AudioResponseFilters, type Course } from "@/lib/api";
import PageLayout from "@/components/PageLayout";
import AuthGuard from "@/components/AuthGuard";
import { useAuth } from "@/context/AuthContext";
export default function AudioEvaluationsPage() {
const router = useRouter();
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [evaluations, setEvaluations] = useState<AudioResponse[]>([]);
const [courses, setCourses] = useState<Course[]>([]);
const [selectedEvaluation, setSelectedEvaluation] = useState<AudioResponse | null>(null);
const [teacherScore, setTeacherScore] = useState<number>(50);
const [teacherFeedback, setTeacherFeedback] = useState<string>("");
const [filters, setFilters] = useState<AudioResponseFilters>({});
const [filters, setFilters] = useState<AudioResponseFilters>({ status: 'pending_instructor' });
const [playingId, setPlayingId] = useState<string | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
@@ -43,10 +46,23 @@ export default function AudioEvaluationsPage() {
}
};
const fetchCourses = async () => {
try {
const data = await cmsApi.getCourses();
setCourses(data);
} catch (error) {
console.error("Error fetching courses for audio evaluations:", error);
}
};
useEffect(() => {
fetchEvaluations();
}, [filters]);
useEffect(() => {
fetchCourses();
}, []);
const handlePlayAudio = async (id: string) => {
if (playingId === id) {
// Stop playing
@@ -138,6 +154,7 @@ export default function AudioEvaluationsPage() {
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_instructor">Pendientes de Instructor</option>
<option value="pending">Pendiente</option>
<option value="ai_evaluated">Solo IA</option>
<option value="teacher_evaluated">Solo Profesor</option>
@@ -145,14 +162,17 @@ export default function AudioEvaluationsPage() {
</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"
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Curso</label>
<select
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"
/>
>
<option value="">Todos los cursos</option>
{courses.map((course) => (
<option key={course.id} value={course.id}>{course.title}</option>
))}
</select>
</div>
<div>
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Lección ID</label>
@@ -167,7 +187,7 @@ export default function AudioEvaluationsPage() {
</div>
<button
onClick={() => {
setFilters({});
setFilters({ status: user?.role === 'admin' ? 'pending_instructor' : 'ai_evaluated' });
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"
+47
View File
@@ -614,6 +614,20 @@ export interface AssetFilters {
limit?: number;
}
export interface AssetRagIngestResult {
asset_id: string;
source: string;
chunks_ingested: number;
chars_ingested: number;
}
export interface AssetZipImportResult {
imported_assets: number;
rag_ingested_assets: number;
rag_chunks_ingested: number;
failed_entries: string[];
}
export interface Cohort {
id: string;
organization_id: string;
@@ -928,6 +942,39 @@ export const cmsApi = {
},
getCourseAssets: (courseId: string): Promise<Asset[]> => apiFetch(`/api/assets?course_id=${courseId}`),
deleteAsset: (id: string): Promise<void> => apiFetch(`/api/assets/${id}`, { method: 'DELETE' }),
ingestAssetForRag: (id: string): Promise<AssetRagIngestResult> =>
apiFetch(`/api/assets/${id}/ingest-rag`, { method: 'POST' }),
importAssetsZip: (file: File, ingestRag = false, courseId?: string): Promise<AssetZipImportResult> => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
formData.append('ingest_rag', ingestRag ? 'true' : 'false');
if (courseId) formData.append('course_id', courseId);
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`);
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
const selectedOrgId = getSelectedOrgId();
if (selectedOrgId) xhr.setRequestHeader('X-Organization-Id', selectedOrgId);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
let msg = 'ZIP import failed';
try {
msg = JSON.parse(xhr.responseText).message || msg;
} catch { }
reject(new Error(msg));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(formData);
});
},
uploadAsset: (file: File, onProgress?: (pct: number) => void, courseId?: string): Promise<UploadResponse> => {
return new Promise((resolve, reject) => {
const formData = new FormData();