152 lines
5.4 KiB
Python
152 lines
5.4 KiB
Python
|
|
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,
|
||
|
|
)
|
||
|
|
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,
|
||
|
|
)
|