| from __future__ import annotations |
|
|
| import logging |
|
|
| from sqlalchemy import select |
| from sqlalchemy.ext.asyncio import AsyncSession |
|
|
| from app.db.models import SavedWord |
| from app.db.session import get_session_factory |
| from app.pipelines.vocab import extract_vocab |
|
|
| log = logging.getLogger(__name__) |
|
|
|
|
| async def save_word( |
| session: AsyncSession, |
| user_id: int, |
| word: str, |
| meaning: str, |
| context: str | None = None, |
| ) -> None: |
| """Insert or update a vocabulary word for the user.""" |
| stmt = select(SavedWord).where(SavedWord.user_id == user_id, SavedWord.word == word) |
| existing = (await session.execute(stmt)).scalar_one_or_none() |
| if existing is not None: |
| existing.meaning = meaning |
| if context is not None: |
| existing.context = context |
| else: |
| session.add(SavedWord(user_id=user_id, word=word, meaning=meaning, context=context)) |
| await session.commit() |
|
|
|
|
| async def save_reply_vocab(user_id: int, reply_text: str, level: str) -> None: |
| """Extract 1-2 vocabulary words from a tutor reply and save them. Never raises. |
| |
| Fire-and-forget: runs after the voice reply is sent, so the Gemini turn no longer |
| pauses to call a save_word tool mid-generation. Swallows its own errors so it can be |
| spawned as a background task without breaking the reply path. |
| """ |
| try: |
| words = await extract_vocab(reply_text, level) |
| if not words: |
| return |
| factory = get_session_factory() |
| async with factory() as session: |
| for w in words: |
| word = w.word.strip().lower() |
| await save_word(session, user_id, word, w.meaning.strip()) |
| log.info("auto-saved word '%s' for user %d", word, user_id) |
| except Exception: |
| log.warning("save reply vocab failed for user %d", user_id, exc_info=True) |
|
|
|
|
| async def list_words(session: AsyncSession, user_id: int, limit: int = 50) -> list[SavedWord]: |
| """Return saved words for a user, most recent first.""" |
| stmt = ( |
| select(SavedWord) |
| .where(SavedWord.user_id == user_id) |
| .order_by(SavedWord.id.desc()) |
| .limit(limit) |
| ) |
| result = await session.execute(stmt) |
| return list(result.scalars().all()) |
|
|
|
|
| async def delete_word(session: AsyncSession, user_id: int, word: str) -> bool: |
| """Delete a saved word. Returns True if found and deleted.""" |
| stmt = select(SavedWord).where(SavedWord.user_id == user_id, SavedWord.word == word) |
| existing = (await session.execute(stmt)).scalar_one_or_none() |
| if existing is None: |
| return False |
| await session.delete(existing) |
| await session.commit() |
| return True |
|
|