From 95256a9ed30d22b46b88d120e374312328711adb Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 17:29:04 +0200 Subject: [PATCH 1/2] Daily audio: pause generation when the last lesson wasn't engaged with Avoids piling up unheard lessons. 'Engaged' = completed or listened past the halfway point (DailyAudioLesson.is_engaged). - Nightly cron skips a user whose most recent lesson isn't engaged (paused), before doing any LLM work. - get_todays_lesson_for_user(include_paused=True) returns that waiting lesson flagged `paused` for the app to show, instead of nothing. Generation callers use the default (today-only) so a paused older lesson never blocks on-demand creation (change-topic / first day). - get_daily_audio_status surfaces a waiting paused lesson as 'ready' so the nav dot still nudges the learner back. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/generate_daily_audio_lessons.py | 25 ++++++++++++++----- zeeguu/api/endpoints/audio_lessons.py | 5 +++- .../audio_lessons/daily_lesson_generator.py | 17 ++++++++++++- zeeguu/core/model/daily_audio_lesson.py | 23 +++++++++++++++++ zeeguu/core/model/user.py | 6 +++++ 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/tools/generate_daily_audio_lessons.py b/tools/generate_daily_audio_lessons.py index d79ef704..941ea23e 100644 --- a/tools/generate_daily_audio_lessons.py +++ b/tools/generate_daily_audio_lessons.py @@ -130,6 +130,15 @@ def resolve_suggestion(user, lesson_type, raw_suggestion): def generate_for_user(user, lesson_type, raw_suggestion, timezone_offset): """Run the full prepare+generate pipeline synchronously for one user. Returns one of: "generated", "exists", "skipped:", "failed:".""" + # Pause gate (before any LLM work): skip if today's lesson already exists, + # or if the most recent lesson wasn't engaged with (< halfway) — we pause + # generation so unheard lessons don't pile up until the learner returns. + if generator.get_todays_lesson_for_user(user, timezone_offset).get("lesson_id"): + return "exists" + latest = DailyAudioLesson.latest_for_language(user, user.learned_language.id) + if latest and not latest.is_engaged: + return "skipped:paused" + try: canonical, is_general = resolve_suggestion(user, lesson_type, raw_suggestion) except ValueError as e: @@ -217,14 +226,18 @@ def timeout_handler(signum, frame): if DRY_RUN: # Read-only: don't create a progress record or generate. - existing = generator.get_todays_lesson_for_user(user, timezone_offset) - if existing.get("lesson_id"): + if generator.get_todays_lesson_for_user(user, timezone_offset).get("lesson_id"): output(f"{index}. {user.name} [{user.learned_language.name}] — already has today's lesson") counts["exists"] += 1 - else: - output(f"{index}. {user.name} [{user.learned_language.name}] — WOULD generate {lesson_type}: {subject}") - counts["would-generate"] += 1 - language_breakdown[user.learned_language.name] += 1 + continue + latest = DailyAudioLesson.latest_for_language(user, user.learned_language.id) + if latest and not latest.is_engaged: + output(f"{index}. {user.name} [{user.learned_language.name}] — paused (last lesson < 50% listened)") + counts["paused"] += 1 + continue + output(f"{index}. {user.name} [{user.learned_language.name}] — WOULD generate {lesson_type}: {subject}") + counts["would-generate"] += 1 + language_breakdown[user.learned_language.name] += 1 continue outcome = generate_for_user(user, lesson_type, raw_suggestion, timezone_offset) diff --git a/zeeguu/api/endpoints/audio_lessons.py b/zeeguu/api/endpoints/audio_lessons.py index 99d6445c..2961657d 100644 --- a/zeeguu/api/endpoints/audio_lessons.py +++ b/zeeguu/api/endpoints/audio_lessons.py @@ -307,7 +307,10 @@ def get_todays_lesson(): # Get timezone offset from query parameter (default to 0 for UTC) timezone_offset = flask.request.args.get("timezone_offset", 0, type=int) - result = generator.get_todays_lesson_for_user(user, timezone_offset) + # include_paused: when there's no lesson today but the last one wasn't + # engaged with (< halfway), surface it flagged `paused` so the app shows the + # waiting lesson rather than triggering a new generation. + result = generator.get_todays_lesson_for_user(user, timezone_offset, include_paused=True) # Check if there's a specific status code to return status_code = result.pop("status_code", 200) diff --git a/zeeguu/core/audio_lessons/daily_lesson_generator.py b/zeeguu/core/audio_lessons/daily_lesson_generator.py index 7008af4d..ce01c670 100644 --- a/zeeguu/core/audio_lessons/daily_lesson_generator.py +++ b/zeeguu/core/audio_lessons/daily_lesson_generator.py @@ -673,7 +673,7 @@ def get_daily_lesson_for_user(self, user, lesson_id=None): return self._format_lesson_response(lesson) - def get_todays_lesson_for_user(self, user, timezone_offset=0): + def get_todays_lesson_for_user(self, user, timezone_offset=0, include_paused=False): """ Get today's daily audio lesson for a user. @@ -701,6 +701,21 @@ def get_todays_lesson_for_user(self, user, timezone_offset=0): ) if not lesson: + # No lesson today. For the app view (include_paused), if the most + # recent lesson wasn't engaged with (< halfway) generation is PAUSED + # — surface that waiting lesson flagged `paused` so the app shows it + # instead of making a new one. Generation callers pass the default + # (today-only) so a paused older lesson never blocks on-demand + # creation (e.g. change-topic / first day). + if include_paused: + latest = DailyAudioLesson.latest_for_language( + user, user.learned_language.id + ) + if latest and not latest.is_engaged: + response = self._format_lesson_response(latest) + if response.get("lesson_id"): + response["paused"] = True + return response return {"lesson": None, "message": "No lesson generated yet today"} return self._format_lesson_response(lesson) diff --git a/zeeguu/core/model/daily_audio_lesson.py b/zeeguu/core/model/daily_audio_lesson.py index 0e67cc02..fcfb3018 100644 --- a/zeeguu/core/model/daily_audio_lesson.py +++ b/zeeguu/core/model/daily_audio_lesson.py @@ -164,6 +164,20 @@ def is_paused(self): completion, since a completed lesson can be replayed and paused.""" return self.pause_position_seconds > 0 + @property + def is_engaged(self): + """Did the learner get at least halfway through (or finish)? + + Daily pre-generation pauses when the most recent lesson wasn't engaged + with, so unheard lessons don't pile up — see the daily cron and + get_todays_lesson_for_user. + """ + if self.is_completed: + return True + if self.duration_seconds and self.pause_position_seconds: + return self.pause_position_seconds >= 0.5 * self.duration_seconds + return False + def display_title(self): """ Best-effort human-readable title for this lesson. @@ -239,3 +253,12 @@ def find_latest_for_user(cls, user, include_completed=False): query = query.filter(cls.last_completed_at.is_(None)) return query.order_by(cls.recommended_at.desc()).first() + @classmethod + def latest_for_language(cls, user, language_id): + """Most recent lesson (any completion state) for this user + language.""" + return ( + cls.query.filter_by(user_id=user.id, language_id=language_id) + .order_by(cls.id.desc()) + .first() + ) + diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 084597f2..1527fd4a 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -1310,6 +1310,12 @@ def get_daily_audio_status(self): ) if not lesson: + # No lesson today — but if the most recent one wasn't engaged with, + # generation is paused and that lesson is still waiting to be played, + # so surface it as "ready" (the nav dot nudges the learner back). + latest = DailyAudioLesson.latest_for_language(self, self.learned_language_id) + if latest and not latest.is_engaged: + return "ready" # Check if generation is feasible before showing "available" if not self._is_audio_lesson_feasible(): return None # Unfeasible - don't show any dot From 275f4a8bc962835362e176c3de348e7234e4e9ad Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 17:43:14 +0200 Subject: [PATCH 2/2] Daily audio pause: address code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - is_engaged: fall back to 'started it' when duration_seconds is missing, rather than pausing a user forever on a data anomaly. ENGAGEMENT_THRESHOLD constant replaces the inline 0.5. - Single source of truth: DailyAudioLesson.waiting_paused_for(user, language_id) used by the endpoint, get_daily_audio_status, and the cron (gate + dry-run) — removes the duplicated 'latest and not is_engaged' checks (and the learned_language.id vs learned_language_id drift). - Cron uses a cheap today_lesson_exists() scalar query instead of building a full lesson response just to test existence — also avoids mislabeling a stale (audio-missing) today lesson as 'paused'. - Remove now-dead find_latest_for_user (zero callers; near-duplicate of latest_for_language with divergent ordering). Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/generate_daily_audio_lessons.py | 14 ++++---- .../audio_lessons/daily_lesson_generator.py | 22 ++++++++++-- zeeguu/core/model/daily_audio_lesson.py | 34 ++++++++++++------- zeeguu/core/model/user.py | 9 +++-- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/tools/generate_daily_audio_lessons.py b/tools/generate_daily_audio_lessons.py index 941ea23e..c0eae517 100644 --- a/tools/generate_daily_audio_lessons.py +++ b/tools/generate_daily_audio_lessons.py @@ -131,12 +131,11 @@ def generate_for_user(user, lesson_type, raw_suggestion, timezone_offset): """Run the full prepare+generate pipeline synchronously for one user. Returns one of: "generated", "exists", "skipped:", "failed:".""" # Pause gate (before any LLM work): skip if today's lesson already exists, - # or if the most recent lesson wasn't engaged with (< halfway) — we pause - # generation so unheard lessons don't pile up until the learner returns. - if generator.get_todays_lesson_for_user(user, timezone_offset).get("lesson_id"): + # or if generation is paused (most recent lesson not engaged with) — so + # unheard lessons don't pile up until the learner returns. + if generator.today_lesson_exists(user, timezone_offset): return "exists" - latest = DailyAudioLesson.latest_for_language(user, user.learned_language.id) - if latest and not latest.is_engaged: + if DailyAudioLesson.waiting_paused_for(user, user.learned_language.id): return "skipped:paused" try: @@ -226,12 +225,11 @@ def timeout_handler(signum, frame): if DRY_RUN: # Read-only: don't create a progress record or generate. - if generator.get_todays_lesson_for_user(user, timezone_offset).get("lesson_id"): + if generator.today_lesson_exists(user, timezone_offset): output(f"{index}. {user.name} [{user.learned_language.name}] — already has today's lesson") counts["exists"] += 1 continue - latest = DailyAudioLesson.latest_for_language(user, user.learned_language.id) - if latest and not latest.is_engaged: + if DailyAudioLesson.waiting_paused_for(user, user.learned_language.id): output(f"{index}. {user.name} [{user.learned_language.name}] — paused (last lesson < 50% listened)") counts["paused"] += 1 continue diff --git a/zeeguu/core/audio_lessons/daily_lesson_generator.py b/zeeguu/core/audio_lessons/daily_lesson_generator.py index ce01c670..23f82d9c 100644 --- a/zeeguu/core/audio_lessons/daily_lesson_generator.py +++ b/zeeguu/core/audio_lessons/daily_lesson_generator.py @@ -673,6 +673,22 @@ def get_daily_lesson_for_user(self, user, lesson_id=None): return self._format_lesson_response(lesson) + def today_lesson_exists(self, user, timezone_offset=0): + """Cheap existence check for today's lesson — no response formatting, + word extraction, or filesystem stat. Used by the cron so it can skip a + user without building (and discarding) a full lesson response.""" + start_of_today_utc, end_of_today_utc = self._get_user_day_range_utc( + timezone_offset + ) + return ( + DailyAudioLesson.query.with_entities(DailyAudioLesson.id) + .filter_by(user_id=user.id, language_id=user.learned_language.id) + .filter(DailyAudioLesson.created_at >= start_of_today_utc) + .filter(DailyAudioLesson.created_at <= end_of_today_utc) + .first() + is not None + ) + def get_todays_lesson_for_user(self, user, timezone_offset=0, include_paused=False): """ Get today's daily audio lesson for a user. @@ -708,11 +724,11 @@ def get_todays_lesson_for_user(self, user, timezone_offset=0, include_paused=Fal # (today-only) so a paused older lesson never blocks on-demand # creation (e.g. change-topic / first day). if include_paused: - latest = DailyAudioLesson.latest_for_language( + paused = DailyAudioLesson.waiting_paused_for( user, user.learned_language.id ) - if latest and not latest.is_engaged: - response = self._format_lesson_response(latest) + if paused: + response = self._format_lesson_response(paused) if response.get("lesson_id"): response["paused"] = True return response diff --git a/zeeguu/core/model/daily_audio_lesson.py b/zeeguu/core/model/daily_audio_lesson.py index fcfb3018..9c06370e 100644 --- a/zeeguu/core/model/daily_audio_lesson.py +++ b/zeeguu/core/model/daily_audio_lesson.py @@ -164,19 +164,25 @@ def is_paused(self): completion, since a completed lesson can be replayed and paused.""" return self.pause_position_seconds > 0 + # Fraction of a lesson the learner must reach for it to count as "engaged". + # Below this, daily generation pauses so unheard lessons don't pile up. + ENGAGEMENT_THRESHOLD = 0.5 + @property def is_engaged(self): """Did the learner get at least halfway through (or finish)? Daily pre-generation pauses when the most recent lesson wasn't engaged - with, so unheard lessons don't pile up — see the daily cron and + with — see waiting_paused_for, the daily cron, and get_todays_lesson_for_user. """ if self.is_completed: return True - if self.duration_seconds and self.pause_position_seconds: - return self.pause_position_seconds >= 0.5 * self.duration_seconds - return False + if not self.duration_seconds: + # No duration to measure against (data anomaly) — fall back to "did + # they start it" rather than pausing the user forever. + return bool(self.pause_position_seconds) or bool(self.listened_count) + return (self.pause_position_seconds or 0) >= self.ENGAGEMENT_THRESHOLD * self.duration_seconds def display_title(self): """ @@ -245,14 +251,6 @@ def find_canonical_for_raw_suggestion(cls, user, raw_suggestion): ) return result.canonical_suggestion if result else None - @classmethod - def find_latest_for_user(cls, user, include_completed=False): - """Find the most recent audio lesson for a user""" - query = cls.query.filter_by(user=user) - if not include_completed: - query = query.filter(cls.last_completed_at.is_(None)) - return query.order_by(cls.recommended_at.desc()).first() - @classmethod def latest_for_language(cls, user, language_id): """Most recent lesson (any completion state) for this user + language.""" @@ -262,3 +260,15 @@ def latest_for_language(cls, user, language_id): .first() ) + @classmethod + def waiting_paused_for(cls, user, language_id): + """The most recent lesson when daily generation is PAUSED on it — it + exists but wasn't engaged with (< halfway). None when the latest was + engaged (or there is none), meaning generation should proceed. + + Single source of truth for "is this user paused", shared by the + today's-lesson endpoint, the nav-dot status, and the nightly cron. + """ + latest = cls.latest_for_language(user, language_id) + return latest if (latest and not latest.is_engaged) else None + diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 1527fd4a..8869a9ac 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -1310,11 +1310,10 @@ def get_daily_audio_status(self): ) if not lesson: - # No lesson today — but if the most recent one wasn't engaged with, - # generation is paused and that lesson is still waiting to be played, - # so surface it as "ready" (the nav dot nudges the learner back). - latest = DailyAudioLesson.latest_for_language(self, self.learned_language_id) - if latest and not latest.is_engaged: + # No lesson today — but if generation is paused (latest lesson not + # engaged with), it's still waiting to be played, so surface it as + # "ready" (the nav dot nudges the learner back). + if DailyAudioLesson.waiting_paused_for(self, self.learned_language_id): return "ready" # Check if generation is feasible before showing "available" if not self._is_audio_lesson_feasible():