| 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, |
| ) |
|
|