This repository has been archived on 2026-07-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
cloud-reader/backend/apps/books/services/recommendation_engine.py
T
Marko 4a7133465f feat: US #32 Section-based Reading Recommendations
Backend:
- Add ReadingSchedule and MeetingSection models for ebook reading schedules
- Implement recommendation algorithm: greedy partition minimizing per-meeting time variance
- Algorithm respects chapter boundaries, estimates time based on chapter weight
- Add serializers for schedules (list/detail/generate/confirm/update)
- Add ScheduleViewSet with endpoints: list, create (generate), retrieve, regenerate, confirm, update_meeting, reorder_meetings
- Add URL routes for /api/books/schedules/
- Create migration 0004 for new models

Frontend:
- Add types: MeetingSection, ReadingSchedule, GenerateRecommendationsRequest
- Add schedulesApi with methods: getSchedules, generateRecommendations, confirmSchedule, updateMeeting, reorderMeetings, deleteSchedule
- Build RecommendationPage with 4-column meeting layout, drag-and-drop chapter reassignment, generate/regenerate/confirm flow
- Add 'Schedule Reading' menu item to BookContextMenu
- Add route: /books/:ebookId/schedule
2026-06-20 19:31:39 +00:00

230 lines
7.3 KiB
Python

"""Recommendation engine for splitting book chapters into balanced weekly meetings.
Algorithm:
1. Gather all BookChapters for an ebook (flat list, respecting hierarchy).
2. For each chapter, estimate reading time based on content length or default weight.
3. Partition chapters into N meetings (default 4) minimizing per-meeting time variance
while respecting chapter boundaries (never split a chapter).
4. Optionally prefer natural boundaries: if a chapter's children exist,
keep siblings together where possible.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from apps.books.models import BookChapter, EBook, MeetingSection, ReadingSchedule
logger = logging.getLogger(__name__)
DEFAULT_MEETING_COUNT = 4
@dataclass
class ChapterWithWeight:
chapter_id: int
title: str
href: str
index: int
weight: float # estimated reading time weight
children: list[int] = field(default_factory=list)
def _collect_chapters(ebook: EBook) -> list[ChapterWithWeight]:
"""Collect all chapters for an ebook as a flat, ordered list with weights."""
chapters_qs = (
BookChapter.objects.filter(ebook=ebook)
.order_by("index")
.prefetch_related("ebook")
)
chapters = list(chapters_qs)
# Build a map of chapter id -> ChapterWithWeight
result: list[ChapterWithWeight] = []
for ch in chapters:
weight = _estimate_chapter_weight(ch)
result.append(
ChapterWithWeight(
chapter_id=ch.id,
title=ch.title,
href=ch.href or "",
index=ch.index,
weight=weight,
)
)
return result
def _estimate_chapter_weight(chapter: BookChapter) -> float:
"""Estimate reading time weight for a chapter.
Defaults to 1.0 per chapter. Sub-chapters (those with non-empty children)
get a weight proportional to their child count + 1.
"""
children = chapter.children or []
child_count = len(children)
if child_count > 0:
return float(child_count + 1)
return 1.0
def _partition_chapters(
chapters: list[ChapterWithWeight],
num_meetings: int = DEFAULT_MEETING_COUNT,
) -> list[list[ChapterWithWeight]]:
"""Partition chapters into `num_meetings` groups minimizing weight variance.
Uses a greedy approach: compute target weight per meeting, then place
chapters sequentially, starting a new meeting when cumulative weight
exceeds the target and adding the overflow to the next meeting.
Never splits a chapter — boundaries are always at chapter edges.
"""
if not chapters:
return [[] for _ in range(num_meetings)]
total_weight = sum(ch.weight for ch in chapters)
target_weight = total_weight / num_meetings
meetings: list[list[ChapterWithWeight]] = []
current_meeting: list[ChapterWithWeight] = []
current_weight = 0.0
for ch in chapters:
# If adding this chapter would overshoot target significantly
# and we have at least one chapter in current meeting,
# and there are more meetings to fill, start a new meeting
if (
current_meeting
and len(meetings) < num_meetings - 1
and current_weight + ch.weight > target_weight * 1.4
and current_weight >= target_weight * 0.5
):
meetings.append(current_meeting)
current_meeting = []
current_weight = 0.0
current_meeting.append(ch)
current_weight += ch.weight
# Add the last meeting
if current_meeting:
meetings.append(current_meeting)
# Pad or balance: if we have fewer meetings than requested, split the largest
while len(meetings) < num_meetings and len(meetings) >= 1:
# Find the largest meeting to split
largest_idx = max(
range(len(meetings)),
key=lambda i: sum(c.weight for c in meetings[i]),
)
largest = meetings[largest_idx]
if len(largest) <= 1:
break
mid = len(largest) // 2
meetings[largest_idx] = largest[:mid]
meetings.insert(largest_idx + 1, largest[mid:])
# If we have more meetings than requested (shouldn't normally happen),
# merge the smallest adjacent pair
while len(meetings) > num_meetings:
best_merge = min(
range(len(meetings) - 1),
key=lambda i: sum(c.weight for c in meetings[i])
+ sum(c.weight for c in meetings[i + 1]),
)
merged = meetings[best_merge] + meetings[best_merge + 1]
meetings[best_merge] = merged
del meetings[best_merge + 1]
return meetings
def generate_recommendations(
ebook: EBook,
user,
meeting_count: int = DEFAULT_MEETING_COUNT,
) -> ReadingSchedule:
"""Generate section-based reading recommendations for an ebook.
Creates (or replaces) a ReadingSchedule with MeetingSections.
Args:
ebook: The EBook to generate recommendations for.
user: The requesting user.
meeting_count: Number of weekly meetings (default 4).
Returns:
The created ReadingSchedule instance.
"""
chapters = _collect_chapters(ebook)
# Delete any existing schedule for this ebook/user
ReadingSchedule.objects.filter(ebook=ebook, user=user).delete()
schedule = ReadingSchedule.objects.create(
ebook=ebook,
user=user,
meeting_count=meeting_count,
)
if not chapters:
# Create empty meetings
for i in range(meeting_count):
MeetingSection.objects.create(
schedule=schedule,
meeting_index=i,
title=f"Week {i + 1}",
)
return schedule
partition = _partition_chapters(chapters, meeting_count)
for meeting_idx, meeting_chapters in enumerate(partition):
chapter_ids = [ch.chapter_id for ch in meeting_chapters]
total_weight = sum(ch.weight for ch in meeting_chapters)
# Build a descriptive title from first and last chapter
first = meeting_chapters[0].title if meeting_chapters else "No chapters"
last = meeting_chapters[-1].title if len(meeting_chapters) > 1 else ""
if last and last != first:
title = f"{first}{last}"
else:
title = first
# Truncate title if too long
if len(title) > 200:
title = title[:197] + "..."
# Estimate time: ~5 minutes per weight point (adjustable heuristic)
estimated_time = max(5, int(total_weight * 5))
MeetingSection.objects.create(
schedule=schedule,
meeting_index=meeting_idx,
title=title,
estimated_time_minutes=estimated_time,
chapter_ids=chapter_ids,
)
return schedule
def regenerate_recommendations(schedule: ReadingSchedule) -> ReadingSchedule:
"""Regenerate recommendations for an existing schedule."""
return generate_recommendations(
ebook=schedule.ebook,
user=schedule.user,
meeting_count=schedule.meeting_count,
)
def confirm_schedule(schedule: ReadingSchedule) -> ReadingSchedule:
"""Confirm/finalize a reading schedule."""
schedule.confirmed = True
schedule.save(update_fields=["confirmed", "updated_at"])
return schedule