from __future__ import annotations import asyncio import re from fastapi import APIRouter, Depends, HTTPException, UploadFile from app.api.auth import get_current_user_id from app.api.schemas import ( GrammarAnswerRequest, GrammarAnswerResponse, GrammarExerciseResponse, MasterRequest, PronunciationErrorResponse, ScoreResponse, ) from app.db.models import GrammarExercise from app.db.session import get_session_factory from app.pipelines.audio import ogg_opus_to_pcm16 from app.pipelines.pronunciation import score_word_pronunciation, word_ipa from app.services.grammar_exercise import get_due_exercises, update_sm2 from app.services.pronunciation import get_top_errors, mark_mastered from app.services.user import get_or_create_user router = APIRouter(prefix="/practice", tags=["practice"]) def _normalize(text: str) -> str: return re.sub(r"[^\w\s]", "", text.strip().lower()) @router.get("/pronunciation", response_model=list[PronunciationErrorResponse]) async def get_pronunciation_errors( user_id: int = Depends(get_current_user_id), ) -> list[PronunciationErrorResponse]: """Get top pronunciation errors for practice.""" factory = get_session_factory() async with factory() as s: errors = await get_top_errors(s, user_id, limit=10) ipas = await asyncio.gather(*[word_ipa(e.word_example) for e in errors]) return [ PronunciationErrorResponse( word_example=e.word_example, expected_phoneme=e.expected_phoneme, actual_phoneme=e.actual_phoneme, occurrence_count=e.occurrence_count, ipa=ipa, ) for e, ipa in zip(errors, ipas, strict=True) ] @router.post("/pronunciation/score", response_model=ScoreResponse) async def score_pronunciation( audio: UploadFile, word: str, user_id: int = Depends(get_current_user_id), ) -> ScoreResponse: """Score pronunciation of a word from uploaded audio.""" ogg_bytes = await audio.read() pcm = await ogg_opus_to_pcm16(ogg_bytes) async with get_session_factory()() as s: user = await get_or_create_user(s, user_id) is_correct, score, tip, assessed = await score_word_pronunciation( pcm, word, lang=user.interface_language ) return ScoreResponse(is_correct=is_correct, score=score, tip=tip, assessed=assessed) @router.post("/pronunciation/master") async def master_word( body: MasterRequest, user_id: int = Depends(get_current_user_id), ) -> dict[str, str]: """Mark a word as mastered.""" factory = get_session_factory() async with factory() as s: await mark_mastered(s, user_id, body.word) return {"status": "mastered"} @router.get("/grammar", response_model=list[GrammarExerciseResponse]) async def get_grammar_exercises( user_id: int = Depends(get_current_user_id), ) -> list[GrammarExerciseResponse]: """Get due grammar exercises.""" factory = get_session_factory() async with factory() as s: exercises = await get_due_exercises(s, user_id, limit=10) return [ GrammarExerciseResponse( id=e.id, wrong_text=e.wrong_text, correct_text=e.correct_text, rule=e.rule, ) for e in exercises ] @router.post("/grammar/answer", response_model=GrammarAnswerResponse) async def answer_grammar( body: GrammarAnswerRequest, user_id: int = Depends(get_current_user_id), ) -> GrammarAnswerResponse: """Check grammar exercise answer and update SM-2.""" factory = get_session_factory() async with factory() as s: ex = await s.get(GrammarExercise, body.exercise_id) if ex is None or ex.user_id != user_id: raise HTTPException(status_code=404, detail="exercise not found") is_correct = _normalize(body.answer) == _normalize(ex.correct_text) quality = 4 if is_correct else 1 await update_sm2(s, body.exercise_id, user_id, quality) return GrammarAnswerResponse( is_correct=is_correct, correct_text=ex.correct_text, quality=quality, )