Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions tools/migrations/26-04-03--add_suggestion_type.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 8 additions & 8 deletions zeeguu/api/endpoints/audio_lessons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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"):
Expand Down
46 changes: 30 additions & 16 deletions zeeguu/core/audio_lessons/daily_lesson_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down
17 changes: 12 additions & 5 deletions zeeguu/core/audio_lessons/script_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions zeeguu/core/model/audio_lesson_meaning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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()
8 changes: 5 additions & 3 deletions zeeguu/core/model/daily_audio_lesson.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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

Expand Down
Loading