Spaces:
Sleeping
Sleeping
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // src/lib/api/aiApi.ts | |
| // Direct integration with the Quran AI analysis service. | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const AI_BASE_URL = 'https://johanne-totipotent-gaylene.ngrok-free.dev'; | |
| // βββ AI Response Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface TashkeelCompare { | |
| true_char_tashkeel: string; | |
| false_char_tashkeel: string; | |
| reason: string; | |
| } | |
| export interface TashkeelStageItem { | |
| true_word_tashkeel: string; | |
| false_word_tashkeel: string; | |
| tashkeel_compare: TashkeelCompare[]; | |
| } | |
| export interface AyahResult { | |
| ayah_number: number; | |
| ayah_text: string; | |
| words_stage: { | |
| extra_words: string[]; | |
| missed_words: string[]; | |
| }; | |
| tashkeel_stage: TashkeelStageItem[]; | |
| } | |
| export interface AiAnalysisResponse { | |
| surah_info: { | |
| surah_number: number; | |
| surah_name: string; | |
| start_ayah: number; | |
| end_ayah: number; | |
| }; | |
| overall_stats: { | |
| word_accuracy_percentage: number; | |
| tashkeel_accuracy_percentage: number; | |
| }; | |
| model_used: string; | |
| user_text: string; | |
| quran_text: string; | |
| ayahs: AyahResult[]; | |
| } | |
| // βββ Health Check βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export async function checkAiHealth(): Promise<boolean> { | |
| try { | |
| const res = await fetch(`${AI_BASE_URL}/`, { | |
| method: 'GET', | |
| headers: { 'ngrok-skip-browser-warning': 'true' }, | |
| }); | |
| return res.ok; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| // βββ Analyze Recitation βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export async function analyzeRecitation( | |
| audio: Blob | File, | |
| surahNumber: number, | |
| ): Promise<AiAnalysisResponse> { | |
| const form = new FormData(); | |
| // Determine filename/extension from mime type | |
| const mime = audio instanceof File ? audio.type : audio.type; | |
| const ext = mime.includes('wav') | |
| ? 'wav' | |
| : mime.includes('mpeg') || mime.includes('mp3') | |
| ? 'mp3' | |
| : mime.includes('m4a') | |
| ? 'm4a' | |
| : 'webm'; | |
| const filename = audio instanceof File ? audio.name : `recording.${ext}`; | |
| form.append('audio', audio, filename); | |
| form.append('surah_number', String(surahNumber)); | |
| const res = await fetch(`${AI_BASE_URL}/analyze`, { | |
| method: 'POST', | |
| headers: { 'ngrok-skip-browser-warning': 'true' }, | |
| body: form, | |
| }); | |
| if (!res.ok) { | |
| const msg = await res.text().catch(() => res.statusText); | |
| throw new Error(`AI analysis failed (${res.status}): ${msg}`); | |
| } | |
| return res.json() as Promise<AiAnalysisResponse>; | |
| } | |
| // βββ Map AI response β RecitationDetailDto βββββββββββββββββββββββββββββββββββ | |
| // Derives all display fields from the AI JSON so the UI doesn't need to change. | |
| import type { RecitationDetailDto } from './studentApi'; | |
| export function mapAiResponseToDto( | |
| ai: AiAnalysisResponse, | |
| recitationId = 0, | |
| ): RecitationDetailDto { | |
| // Collect incorrect (missed) words across all ayahs | |
| const incorrectWords: string[] = ai.ayahs.flatMap((a) => a.words_stage.missed_words); | |
| // Collect tashkeel mistakes as human-readable strings | |
| const tashkeelMistakes: string[] = ai.ayahs.flatMap((a) => | |
| a.tashkeel_stage.flatMap((ts) => | |
| ts.tashkeel_compare.map( | |
| (tc) => `${tc.false_char_tashkeel} β ${tc.true_char_tashkeel}: ${tc.reason}`, | |
| ), | |
| ), | |
| ); | |
| // Scores come directly from the model (already 0-100) | |
| const wordScore = ai.overall_stats.word_accuracy_percentage; | |
| const tashkeelScore = ai.overall_stats.tashkeel_accuracy_percentage; | |
| const overallScore = Math.round((wordScore + tashkeelScore) / 2 * 100) / 100; | |
| return { | |
| recitationId, | |
| surahName: ai.surah_info.surah_name, | |
| startAyah: ai.surah_info.start_ayah, | |
| endAyah: ai.surah_info.end_ayah, | |
| recitationText: ai.user_text, | |
| userText: ai.user_text, | |
| wordScore, | |
| tashkeelScore, | |
| totalScore: overallScore, | |
| overallScore, | |
| wordMistakesCount: incorrectWords.length, | |
| tashkeelMistakesCount: tashkeelMistakes.length, | |
| tajweedMistakesCount: 0, | |
| incorrectWords, | |
| tashkeelMistakes, | |
| tajweedMistakes: [], | |
| feedback: undefined, | |
| createdAt: new Date().toISOString(), | |
| }; | |
| } | |
| // βββ Save AI result to backend DB ββββββββββββββββββββββββββββββββββββββββββββ | |
| // The backend POST /api/student/recitations only accepts multipart/form-data, | |
| // not JSON. We build a FormData payload and attach the auth token manually. | |
| import { getApiBaseUrl } from './config'; | |
| const BACKEND_BASE_URL = getApiBaseUrl(); | |
| export async function saveRecitationToBackend( | |
| ai: AiAnalysisResponse, | |
| ): Promise<RecitationDetailDto | null> { | |
| const form = new FormData(); | |
| form.append('surahName', ai.surah_info.surah_name); | |
| form.append('surahNumber', String(ai.surah_info.surah_number)); | |
| form.append('ayahStart', String(ai.surah_info.start_ayah)); | |
| form.append('ayahEnd', String(ai.surah_info.end_ayah)); | |
| form.append('userText', ai.user_text); | |
| form.append('wordScore', String(ai.overall_stats.word_accuracy_percentage)); | |
| form.append('tashkeelScore', String(ai.overall_stats.tashkeel_accuracy_percentage)); | |
| const token = localStorage.getItem('authToken') ?? ''; | |
| const res = await fetch(`${BACKEND_BASE_URL}/api/student/recitations`, { | |
| method: 'POST', | |
| headers: token ? { Authorization: `Bearer ${token}` } : {}, | |
| body: form, | |
| }); | |
| if (!res.ok) return null; | |
| const text = await res.text(); | |
| if (!text) return null; | |
| return JSON.parse(text) as RecitationDetailDto; | |
| } | |