diff --git a/tools/migrations/26-04-03--add_suggestion_type.sql b/tools/migrations/26-04-03--add_suggestion_type.sql new file mode 100644 index 00000000..8ac39e81 --- /dev/null +++ b/tools/migrations/26-04-03--add_suggestion_type.sql @@ -0,0 +1,7 @@ +ALTER TABLE daily_audio_lesson +ADD COLUMN suggestion_type VARCHAR(20) DEFAULT NULL +AFTER topic_suggestion; + +ALTER TABLE audio_lesson_meaning +ADD COLUMN suggestion_type VARCHAR(20) DEFAULT NULL +AFTER topic_suggestion; diff --git a/tools/migrations/26-04-03-b--rename_topic_suggestion_to_suggestion.sql b/tools/migrations/26-04-03-b--rename_topic_suggestion_to_suggestion.sql new file mode 100644 index 00000000..479b05b7 --- /dev/null +++ b/tools/migrations/26-04-03-b--rename_topic_suggestion_to_suggestion.sql @@ -0,0 +1,5 @@ +ALTER TABLE daily_audio_lesson +CHANGE COLUMN topic_suggestion suggestion VARCHAR(100) DEFAULT NULL; + +ALTER TABLE audio_lesson_meaning +CHANGE COLUMN topic_suggestion suggestion VARCHAR(100) DEFAULT NULL; diff --git a/zeeguu/api/endpoints/audio_lessons.py b/zeeguu/api/endpoints/audio_lessons.py index 4fc5f3a6..f9aab488 100644 --- a/zeeguu/api/endpoints/audio_lessons.py +++ b/zeeguu/api/endpoints/audio_lessons.py @@ -4,6 +4,7 @@ from zeeguu.api.utils.json_result import json_result from zeeguu.api.utils.route_wrappers import cross_domain, requires_session from zeeguu.core.audio_lessons.daily_lesson_generator import DailyLessonGenerator +from zeeguu.core.audio_lessons.script_generator import VALID_SUGGESTION_TYPES from zeeguu.core.model import db, User, UserWord, AudioLessonGenerationProgress from zeeguu.logging import log from . import api @@ -57,7 +58,8 @@ def _generate_lesson_in_background(user_id, preparation): translation_language=preparation["translation_language"], cefr_level=preparation["cefr_level"], progress=progress, - topic_suggestion=preparation.get("topic_suggestion"), + suggestion=preparation.get("suggestion"), + suggestion_type=preparation.get("suggestion_type"), ) except Exception as e: log(f"[background_generate] Error for user {user_id}: {e}") @@ -88,14 +90,12 @@ def generate_daily_lesson(): # Get timezone offset from form data (default to 0 for UTC) timezone_offset = flask.request.form.get("timezone_offset", 0, type=int) - topic_suggestion = flask.request.form.get("topic_suggestion", "").strip() - if topic_suggestion: - # Cap at 4 words and 24 characters to limit prompt injection surface - topic_suggestion = " ".join(topic_suggestion.split()[:4])[:24].strip() or None - else: - topic_suggestion = None + suggestion = flask.request.form.get("suggestion", "").strip()[:80].strip() or None + suggestion_type = flask.request.form.get("suggestion_type", "").strip() or None + if suggestion_type not in (None, *VALID_SUGGESTION_TYPES): + suggestion_type = None - result = generator.prepare_lesson_generation(user, timezone_offset, topic_suggestion) + result = generator.prepare_lesson_generation(user, timezone_offset, suggestion, suggestion_type) # Existing lesson found — return it directly if result.get("lesson_id"): diff --git a/zeeguu/core/audio_lessons/daily_lesson_generator.py b/zeeguu/core/audio_lessons/daily_lesson_generator.py index ff9dac9a..eb42d172 100644 --- a/zeeguu/core/audio_lessons/daily_lesson_generator.py +++ b/zeeguu/core/audio_lessons/daily_lesson_generator.py @@ -42,7 +42,7 @@ def lesson_builder(self): self._lesson_builder = LessonBuilder() return self._lesson_builder - def prepare_lesson_generation(self, user, timezone_offset=0, topic_suggestion=None): + def prepare_lesson_generation(self, user, timezone_offset=0, suggestion=None, suggestion_type=None): """ Validate and prepare for lesson generation (synchronous, fast). Returns either an error/existing-lesson dict, or a preparation dict @@ -51,7 +51,8 @@ def prepare_lesson_generation(self, user, timezone_offset=0, topic_suggestion=No Args: user: The User object to generate a lesson for timezone_offset: Client's timezone offset in minutes from UTC - topic_suggestion: Optional short topic hint for the LLM (max 100 chars) + suggestion: Optional short topic hint for the LLM (max 100 chars) + suggestion_type: Optional type of suggestion ("topic" or "situation") Returns: Dictionary with either error info, existing lesson, or preparation data @@ -65,10 +66,13 @@ def prepare_lesson_generation(self, user, timezone_offset=0, topic_suggestion=No if existing_lesson.get("lesson_id"): # If the user provided a topic that differs from the existing lesson's, # delete the old lesson so a new one can be generated with the topic. - existing_topic = existing_lesson.get("topic_suggestion") - if topic_suggestion and topic_suggestion != existing_topic: + existing_topic = existing_lesson.get("suggestion") + existing_type = existing_lesson.get("suggestion_type") + topic_changed = suggestion and suggestion != existing_topic + type_changed = suggestion_type and suggestion_type != existing_type + if topic_changed or type_changed: log( - f"[prepare_lesson_generation] Topic changed ('{existing_topic}' -> '{topic_suggestion}'), deleting existing lesson" + f"[prepare_lesson_generation] Suggestion changed ('{existing_topic}/{existing_type}' -> '{suggestion}/{suggestion_type}'), deleting existing lesson" ) self.delete_todays_lesson_for_user(user, timezone_offset) else: @@ -140,7 +144,8 @@ def prepare_lesson_generation(self, user, timezone_offset=0, topic_suggestion=No "translation_language": translation_language, "cefr_level": cefr_level, "progress_id": progress.id, - "topic_suggestion": topic_suggestion, + "suggestion": suggestion, + "suggestion_type": suggestion_type, } def select_words_for_lesson( @@ -169,7 +174,8 @@ def generate_audio_lesson_meaning( cefr_level, created_by="claude-v1", progress=None, - topic_suggestion=None, + suggestion=None, + suggestion_type=None, ): """ Generate an AudioLessonMeaning for a specific user word. @@ -181,7 +187,8 @@ def generate_audio_lesson_meaning( cefr_level: CEFR level for the lesson created_by: String identifying who created this lesson progress: Optional AudioLessonGenerationProgress for tracking - topic_suggestion: Optional short topic hint for the LLM + suggestion: Optional short topic hint for the LLM + suggestion_type: Optional type ("topic" or "situation") Returns: AudioLessonMeaning object @@ -194,7 +201,7 @@ def generate_audio_lesson_meaning( # Check if audio lesson already exists for this meaning, teacher language, and topic existing_lesson = AudioLessonMeaning.find( - meaning=meaning, teacher_language=teacher_lang, topic_suggestion=topic_suggestion + meaning=meaning, teacher_language=teacher_lang, suggestion=suggestion, suggestion_type=suggestion_type ) if existing_lesson: return existing_lesson @@ -211,7 +218,8 @@ def generate_audio_lesson_meaning( origin_language=origin_language, translation_language=translation_language, cefr_level=cefr_level, - topic_suggestion=topic_suggestion, + suggestion=suggestion, + suggestion_type=suggestion_type, ) # Update progress: script done @@ -226,7 +234,8 @@ def generate_audio_lesson_meaning( created_by=created_by, difficulty_level=cefr_level, teacher_language=teacher_lang, - topic_suggestion=topic_suggestion, + suggestion=suggestion, + suggestion_type=suggestion_type, ) db.session.add(audio_lesson_meaning) db.session.flush() # Get the ID @@ -267,7 +276,8 @@ def generate_daily_lesson( translation_language: str, cefr_level: str, progress: AudioLessonGenerationProgress = None, - topic_suggestion: str = None, + suggestion: str = None, + suggestion_type: str = None, ) -> dict: """ Generate a daily audio lesson for the given user with specific words. @@ -279,7 +289,8 @@ def generate_daily_lesson( origin_language: Language code for the words being learned (e.g. 'es', 'da') translation_language: Language code for translations (e.g. 'en') cefr_level: CEFR level for the lesson (e.g. 'A1', 'B2') - topic_suggestion: Optional short topic hint for the LLM + suggestion: Optional short topic hint for the LLM + suggestion_type: Optional type ("topic" or "situation") Returns: Dictionary with lesson details or error information @@ -296,7 +307,8 @@ def generate_daily_lesson( user=user, created_by="generate_daily_lesson_v1", language=user.learned_language, - topic_suggestion=topic_suggestion, + suggestion=suggestion, + suggestion_type=suggestion_type, ) db.session.add(daily_lesson) log(f"[generate_daily_lesson] Created daily lesson object") @@ -329,7 +341,8 @@ def generate_daily_lesson( audio_lesson_meaning = self.generate_audio_lesson_meaning( user_word, origin_language, translation_language, cefr_level, progress=progress, - topic_suggestion=topic_suggestion, + suggestion=suggestion, + suggestion_type=suggestion_type, ) except Exception as e: log( @@ -480,7 +493,8 @@ def _format_lesson_response(self, lesson): "is_paused": lesson.is_paused, "is_completed": lesson.is_completed, "listened_count": lesson.listened_count, - "topic_suggestion": lesson.topic_suggestion, + "suggestion": lesson.suggestion, + "suggestion_type": lesson.suggestion_type, } def get_daily_lesson_for_user(self, user, lesson_id=None): diff --git a/zeeguu/core/audio_lessons/script_generator.py b/zeeguu/core/audio_lessons/script_generator.py index 3bef1b79..9bb60b70 100644 --- a/zeeguu/core/audio_lessons/script_generator.py +++ b/zeeguu/core/audio_lessons/script_generator.py @@ -6,6 +6,8 @@ from zeeguu.core.llm_services import generate_audio_lesson_script from zeeguu.logging import log +VALID_SUGGESTION_TYPES = ("topic", "situation") + # Load the prompt template def get_prompt_template(file_name) -> str: @@ -24,7 +26,8 @@ def generate_lesson_script( translation_language: str, cefr_level: str = "A1", generator_prompt_file="meaning_lesson--teacher_challenges_both_dialogue_and_beyond-v2.txt", - topic_suggestion: str = None, + suggestion: str = None, + suggestion_type: str = None, ) -> str: """ Generate a lesson script using Claude API. @@ -36,7 +39,8 @@ def generate_lesson_script( translation_language: Language code of the translation (e.g., 'en') cefr_level: Cefr level of the word being learned generator_prompt_file: full filename - topic_suggestion: Optional short topic hint for the LLM + suggestion: Optional short topic hint for the LLM + suggestion_type: Optional type ("topic" or "situation") Returns: Generated script text @@ -77,10 +81,13 @@ def generate_lesson_script( cefr_level=cefr_level, ) - if topic_suggestion: - prompt += f'\nCONTEXT: Set the dialogue scenario in a context related to "{topic_suggestion}". The examples and challenges should use vocabulary relevant to this topic.\n' + if suggestion: + if suggestion_type == "situation": + prompt += f'\nSITUATION: Structure the lesson as a roleplay scenario: "{suggestion}". The dialogue should simulate a real conversation the learner might have in this situation.\n' + else: + prompt += f'\nTOPIC: Set the dialogue scenario in a context related to "{suggestion}". The examples and challenges should use vocabulary relevant to this topic.\n' - log(f"Generating script for {origin_word} -> {translation_word} (topic: {topic_suggestion})") + log(f"Generating script for {origin_word} -> {translation_word} (topic: {suggestion}, type: {suggestion_type})") try: # Use unified LLM service with automatic Anthropic -> DeepSeek fallback diff --git a/zeeguu/core/model/audio_lesson_meaning.py b/zeeguu/core/model/audio_lesson_meaning.py index f2a630cc..aa1dfb89 100644 --- a/zeeguu/core/model/audio_lesson_meaning.py +++ b/zeeguu/core/model/audio_lesson_meaning.py @@ -24,7 +24,8 @@ class AudioLessonMeaning(db.Model): script = Column(Text, nullable=False) voice_config = Column(JSON) - topic_suggestion = Column(String(100), nullable=True) + suggestion = Column(String(100), nullable=True) + suggestion_type = Column(String(20), nullable=True) teacher_language_id = Column(Integer, ForeignKey(Language.id), nullable=True) teacher_language = relationship(Language) @@ -46,7 +47,8 @@ def __init__( voice_config=None, duration_seconds=None, teacher_language=None, - topic_suggestion=None, + suggestion=None, + suggestion_type=None, ): self.meaning_id = meaning.id self.script = script @@ -55,7 +57,8 @@ def __init__( self.lesson_type = lesson_type self.voice_config = voice_config self.duration_seconds = duration_seconds - self.topic_suggestion = topic_suggestion + self.suggestion = suggestion + self.suggestion_type = suggestion_type if teacher_language: self.teacher_language_id = teacher_language.id @@ -69,10 +72,10 @@ def audio_file_path(self): return f"/audio/lessons/{self.meaning_id}-{lang_code}.mp3" @classmethod - def find(cls, meaning, teacher_language=None, topic_suggestion=None): - """Find audio lesson for a specific meaning, teacher language, and topic""" + def find(cls, meaning, teacher_language=None, suggestion=None, suggestion_type=None): + """Find audio lesson for a specific meaning, teacher language, topic, and type""" query = cls.query.filter_by(meaning=meaning) if teacher_language: query = query.filter_by(teacher_language_id=teacher_language.id) - query = query.filter_by(topic_suggestion=topic_suggestion) + query = query.filter_by(suggestion=suggestion, suggestion_type=suggestion_type) return query.first() diff --git a/zeeguu/core/model/daily_audio_lesson.py b/zeeguu/core/model/daily_audio_lesson.py index 83706fe8..85fb6f51 100644 --- a/zeeguu/core/model/daily_audio_lesson.py +++ b/zeeguu/core/model/daily_audio_lesson.py @@ -41,7 +41,8 @@ class DailyAudioLesson(db.Model): pause_position_seconds = Column(Integer, default=0) # Optional topic suggestion that themed the lesson - topic_suggestion = Column(db.String(100), nullable=True) + suggestion = Column(db.String(100), nullable=True) + suggestion_type = Column(db.String(20), nullable=True) # Relationship to segments (individual meaning lessons) segments = relationship( @@ -51,13 +52,14 @@ class DailyAudioLesson(db.Model): cascade="all, delete-orphan", ) - def __init__(self, user, created_by, voice_config=None, duration_seconds=None, language=None, topic_suggestion=None): + def __init__(self, user, created_by, voice_config=None, duration_seconds=None, language=None, suggestion=None, suggestion_type=None): self.user = user self.created_by = created_by self.voice_config = voice_config self.duration_seconds = duration_seconds self.language = language or user.learned_language - self.topic_suggestion = topic_suggestion + self.suggestion = suggestion + self.suggestion_type = suggestion_type self.listened_count = 0 self.pause_position_seconds = 0