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
23 changes: 17 additions & 6 deletions tools/generate_daily_audio_lessons.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ 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:<reason>", "failed:<reason>"."""
# Pause gate (before any LLM work): skip if today's lesson already exists,
# 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"
if DailyAudioLesson.waiting_paused_for(user, user.learned_language.id):
return "skipped:paused"

try:
canonical, is_general = resolve_suggestion(user, lesson_type, raw_suggestion)
except ValueError as e:
Expand Down Expand Up @@ -217,14 +225,17 @@ 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.today_lesson_exists(user, timezone_offset):
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
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
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)
Expand Down
5 changes: 4 additions & 1 deletion zeeguu/api/endpoints/audio_lessons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 32 additions & 1 deletion zeeguu/core/audio_lessons/daily_lesson_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,23 @@ 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 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.

Expand Down Expand Up @@ -701,6 +717,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:
paused = DailyAudioLesson.waiting_paused_for(
user, user.learned_language.id
)
if paused:
response = self._format_lesson_response(paused)
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)
Expand Down
45 changes: 39 additions & 6 deletions zeeguu/core/model/daily_audio_lesson.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,26 @@ 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 — see waiting_paused_for, the daily cron, and
get_todays_lesson_for_user.
"""
if self.is_completed:
return True
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):
"""
Best-effort human-readable title for this lesson.
Expand Down Expand Up @@ -232,10 +252,23 @@ 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()
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()
)

@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

5 changes: 5 additions & 0 deletions zeeguu/core/model/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,11 @@ def get_daily_audio_status(self):
)

if not lesson:
# 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():
return None # Unfeasible - don't show any dot
Expand Down
Loading