language-learning-app/api/app/domain/services/flashcard_service.py

153 lines
5.5 KiB
Python
Raw Normal View History

import uuid
from ..models.flashcard import Flashcard, FlashcardEvent
from ...outbound.postgres.repositories.flashcard_repository import FlashcardRepository
from ...outbound.postgres.repositories.vocab_repository import VocabRepository
from ...outbound.postgres.repositories.dictionary_repository import DictionaryRepository
VALID_DIRECTIONS = {"target_to_en", "en_to_target"}
VALID_EVENT_TYPES = {"shown", "answered", "skipped"}
class FlashcardService:
"""Generates flashcards from resolved vocab bank entries and records study events.
Flashcard text is derived directly from the dictionary: the lemma headword is the
target-language side and the sense gloss is the English side. Both directions are
created by default.
Usage::
service = FlashcardService(
flashcard_repo=PostgresFlashcardRepository(db),
vocab_repo=PostgresVocabRepository(db),
dict_repo=PostgresDictionaryRepository(db),
)
# Generate both directions for a resolved bank entry
cards = await service.generate_flashcard_from_entry(entry_id)
# Record that the user answered correctly
event = await service.record_flashcard_event(
flashcard_id=cards[0].id,
user_id=user_id,
event_type="answered",
response="banque",
)
"""
def __init__(
self,
flashcard_repo: FlashcardRepository,
vocab_repo: VocabRepository,
dict_repo: DictionaryRepository,
) -> None:
self.flashcard_repo = flashcard_repo
self.vocab_repo = vocab_repo
self.dict_repo = dict_repo
async def generate_flashcard_from_entry(
self,
entry_id: uuid.UUID,
direction: str | None = None,
) -> list[Flashcard]:
"""Create flashcard(s) from a vocab bank entry that has a resolved sense.
Looks up the sense gloss (English meaning) and lemma headword (target-language
word) and creates one card per direction. Pass ``direction`` to generate only
``"target_to_en"`` or ``"en_to_target"``; omit it to create both.
Raises ``ValueError`` if the entry does not exist, has no resolved sense, or
if the underlying sense or lemma rows cannot be found in the dictionary.
Usage::
# Both directions — typical case
cards = await service.generate_flashcard_from_entry(entry_id)
assert len(cards) == 2
# One direction only
cards = await service.generate_flashcard_from_entry(
entry_id, direction="target_to_en"
)
"""
if direction is not None and direction not in VALID_DIRECTIONS:
raise ValueError(f"Invalid direction '{direction}'. Must be one of {VALID_DIRECTIONS}")
entry = await self.vocab_repo.get_entry(entry_id)
if entry is None:
raise ValueError(f"Bank entry {entry_id} not found")
if entry.sense_id is None:
raise ValueError(
"Entry has no resolved sense; disambiguate before generating flashcards"
)
sense = await self.dict_repo.get_sense(uuid.UUID(entry.sense_id))
if sense is None:
raise ValueError(f"Sense {entry.sense_id} not found in dictionary")
lemma = await self.dict_repo.get_lemma(uuid.UUID(sense.lemma_id))
if lemma is None:
raise ValueError(f"Lemma for sense {entry.sense_id} not found in dictionary")
pair = await self.vocab_repo.get_language_pair(uuid.UUID(entry.language_pair_id))
if pair is None:
raise ValueError(f"Language pair {entry.language_pair_id} not found")
user_id = uuid.UUID(entry.user_id)
directions = [direction] if direction else ["target_to_en", "en_to_target"]
flashcards = []
for d in directions:
if d == "target_to_en":
prompt, answer = lemma.headword, sense.gloss
else:
prompt, answer = sense.gloss, lemma.headword
card = await self.flashcard_repo.create_flashcard(
user_id=user_id,
bank_entry_id=entry_id,
source_lang=pair.source_lang,
target_lang=pair.target_lang,
prompt_text=prompt,
answer_text=answer,
card_direction=d,
)
flashcards.append(card)
return flashcards
async def record_flashcard_event(
self,
flashcard_id: uuid.UUID,
user_id: uuid.UUID,
event_type: str,
response: str | None = None,
) -> FlashcardEvent:
"""Record a study event against a flashcard — shown, answered, or skipped.
``response`` is the user's free-text answer and is only meaningful for
``event_type="answered"``; it is stored as-is without grading.
Raises ``ValueError`` for unrecognised event types.
Usage::
event = await service.record_flashcard_event(
flashcard_id=card.id,
user_id=user_id,
event_type="answered",
response="banque",
)
"""
if event_type not in VALID_EVENT_TYPES:
raise ValueError(
f"Invalid event_type '{event_type}'. Must be one of {VALID_EVENT_TYPES}"
)
return await self.flashcard_repo.record_event(
flashcard_id=flashcard_id,
user_id=user_id,
event_type=event_type,
user_response=response,
)