import uuid from dataclasses import dataclass from ..models.pack import WordBankPack, WordBankPackEntry, WordBankPackFlashcardTemplate from ...outbound.postgres.repositories.pack_repository import PackRepository from ...outbound.postgres.repositories.vocab_repository import VocabRepository from ...outbound.postgres.repositories.flashcard_repository import FlashcardRepository from ...outbound.postgres.repositories.dictionary_repository import DictionaryRepository class DuplicateEntryError(Exception): """Raised when a pack would add plain cards that are identical to ones already in the bank.""" def __init__(self, duplicate_surface_texts: list[str]) -> None: self.duplicate_surface_texts = duplicate_surface_texts joined = ", ".join(f'"{t}"' for t in duplicate_surface_texts) super().__init__( f"You already have the following word(s) in your bank: {joined}. " "Remove them first, or add the pack once they have been cleared." ) class PackNotFoundError(Exception): pass @dataclass class PackApplicationResult: added_surface_texts: list[str] class PackService: def __init__( self, pack_repo: PackRepository, vocab_repo: VocabRepository, flashcard_repo: FlashcardRepository, dict_repo: DictionaryRepository, ) -> None: self.pack_repo = pack_repo self.vocab_repo = vocab_repo self.flashcard_repo = flashcard_repo self.dict_repo = dict_repo async def create_pack( self, name: str, name_target: str, description: str, description_target: str, source_lang: str, target_lang: str, proficiencies: list[str], ) -> WordBankPack: return await self.pack_repo.create_pack( name=name, name_target=name_target, description=description, description_target=description_target, source_lang=source_lang, target_lang=target_lang, proficiencies=proficiencies, ) async def update_pack( self, pack_id: uuid.UUID, name: str | None = None, name_target: str | None = None, description: str | None = None, description_target: str | None = None, proficiencies: list[str] | None = None, ) -> WordBankPack: pack = await self.pack_repo.get_pack(pack_id) if pack is None: raise PackNotFoundError(f"Pack {pack_id} not found") return await self.pack_repo.update_pack( pack_id=pack_id, name=name, name_target=name_target, description=description, description_target=description_target, proficiencies=proficiencies, ) async def publish_pack(self, pack_id: uuid.UUID) -> WordBankPack: pack = await self.pack_repo.get_pack(pack_id) if pack is None: raise PackNotFoundError(f"Pack {pack_id} not found") return await self.pack_repo.publish_pack(pack_id) async def add_entry_to_pack( self, pack_id: uuid.UUID, sense_id: uuid.UUID | None, surface_text: str, ) -> WordBankPackEntry: pack = await self.pack_repo.get_pack(pack_id) if pack is None: raise PackNotFoundError(f"Pack {pack_id} not found") return await self.pack_repo.add_entry( pack_id=pack_id, sense_id=sense_id, surface_text=surface_text, ) async def add_flashcard_template_to_entry( self, pack_entry_id: uuid.UUID, card_direction: str, prompt_text: str, answer_text: str, prompt_context_text: str | None = None, answer_context_text: str | None = None, ) -> WordBankPackFlashcardTemplate: return await self.pack_repo.add_flashcard_template( pack_entry_id=pack_entry_id, card_direction=card_direction, prompt_text=prompt_text, answer_text=answer_text, prompt_context_text=prompt_context_text, answer_context_text=answer_context_text, ) async def add_pack_to_user_bank( self, pack_id: uuid.UUID, user_id: uuid.UUID, source_lang: str, target_lang: str, ) -> PackApplicationResult: pack = await self.pack_repo.get_pack(pack_id) if pack is None or not pack.is_published: raise PackNotFoundError(f"Pack {pack_id} not found") entries = await self.pack_repo.get_entries_for_pack(pack_id) if not entries: return PackApplicationResult(added_surface_texts=[]) pair = await self.vocab_repo.get_or_create_language_pair(user_id, source_lang, target_lang) language_pair_id = uuid.UUID(pair.id) entry_ids = [uuid.UUID(e.id) for e in entries] templates_by_entry = await self.pack_repo.get_templates_for_entries(entry_ids) existing_sense_ids = await self.vocab_repo.get_sense_ids_for_user_in_pair( user_id, language_pair_id ) # Detect plain-card duplicates: entries whose sense is already in the user's bank # and whose templates carry no context text (would produce identical plain cards). duplicates = [] for entry in entries: if entry.sense_id is None or entry.sense_id not in existing_sense_ids: continue entry_templates = templates_by_entry.get(entry.id, []) has_context = any( t.prompt_context_text or t.answer_context_text for t in entry_templates ) if not has_context: duplicates.append(entry.surface_text) if duplicates: raise DuplicateEntryError(duplicates) added: list[str] = [] for entry in entries: bank_entry = await self.vocab_repo.add_entry( user_id=user_id, language_pair_id=language_pair_id, surface_text=entry.surface_text, entry_pathway="pack", sense_id=uuid.UUID(entry.sense_id) if entry.sense_id else None, disambiguation_status="auto_resolved" if entry.sense_id else "pending", pack_entry_id=uuid.UUID(entry.id), ) entry_templates = templates_by_entry.get(entry.id, []) if entry_templates: for template in entry_templates: await self.flashcard_repo.create_flashcard( user_id=user_id, bank_entry_id=uuid.UUID(bank_entry.id), source_lang=pair.source_lang, target_lang=pair.target_lang, prompt_text=template.prompt_text, answer_text=template.answer_text, card_direction=template.card_direction, prompt_context_text=template.prompt_context_text, answer_context_text=template.answer_context_text, source_pack_flashcard_template_id=uuid.UUID(template.id), ) elif entry.sense_id: # Fallback: no templates — generate plain direction cards from the dictionary await self._generate_plain_cards( bank_entry_id=uuid.UUID(bank_entry.id), user_id=user_id, sense_id=uuid.UUID(entry.sense_id), source_lang=pair.source_lang, target_lang=pair.target_lang, ) added.append(entry.surface_text) return PackApplicationResult(added_surface_texts=added) async def _generate_plain_cards( self, bank_entry_id: uuid.UUID, user_id: uuid.UUID, sense_id: uuid.UUID, source_lang: str, target_lang: str, ) -> None: sense = await self.dict_repo.get_sense(sense_id) if sense is None: return lemma = await self.dict_repo.get_lemma(uuid.UUID(sense.lemma_id)) if lemma is None: return for direction in ("target_to_source", "source_to_target"): if direction == "target_to_source": prompt, answer = lemma.headword, sense.gloss else: prompt, answer = sense.gloss, lemma.headword await self.flashcard_repo.create_flashcard( user_id=user_id, bank_entry_id=bank_entry_id, source_lang=source_lang, target_lang=target_lang, prompt_text=prompt, answer_text=answer, card_direction=direction, )