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:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user