"""Skill-upgrade suggestion service — closed learning loop (Module B). See research/08-next-modules-build-plan.md §Module B. This is a stateless derivation: suggestions are recomputed on demand from UserProgress + SkillResource + UserSkill state. The only persisted bit is the dismissal timestamp on UserSkill.dismissed_upgrade_at. """ from __future__ import annotations from dataclasses import asdict, dataclass from typing import Iterable from django.db import transaction from django.db.models import Max from django.utils import timezone from apps.resources.models import SkillResource from apps.skills.models import Skill, UserSkill from apps.skills.utils import proficiency_to_level from .models import UserProgress # Proficiency bump ladder — deterministic, documented. # <40 → 60, 40–59 → 70, 60–79 → 85, 80–99 → 100, 100 → no suggestion. _BUMPS: list[tuple[int, int]] = [ (40, 60), (60, 70), (80, 85), (100, 100), ] def _suggested_proficiency(current: int) -> int | None: if current >= 100: return None for upper, target in _BUMPS: if current < upper: return target return None @dataclass class UpgradeSuggestion: skill_id: int skill_name: str current_proficiency: int suggested_proficiency: int current_level: str suggested_level: str source_resource_ids: list[int] def to_dict(self) -> dict: return asdict(self) def _completed_progress_by_skill(user) -> dict[int, list[dict]]: """Return {skill_id: [{resource_id, completed_at}, ...]} from COMPLETED UserProgress rows whose resources link to skills. """ completed = ( UserProgress.objects .filter(user=user, status='COMPLETED') .values_list('resource_id', 'completed_at') ) resource_rows = list(completed) if not resource_rows: return {} resource_ids = [rid for rid, _ in resource_rows] completed_at_by_res = dict(resource_rows) links = SkillResource.objects.filter(resource_id__in=resource_ids) by_skill: dict[int, list[dict]] = {} for link in links: by_skill.setdefault(link.skill_id, []).append({ 'resource_id': link.resource_id, 'completed_at': completed_at_by_res[link.resource_id], }) return by_skill def _user_skills_by_id(user, skill_ids: Iterable[int]) -> dict[int, UserSkill]: return { us.skill_id: us for us in UserSkill.objects.filter( user=user, skill_id__in=list(skill_ids), ).select_related('skill') } def compute_upgrade_suggestions(user) -> list[dict]: """Pending upgrade suggestions for a user. One suggestion per skill where: * user has ≥1 COMPLETED resource linked to the skill * bump map yields a proficiency > user's current proficiency * the suggestion is not currently dismissed (or dismissal predates the most recent completion — self-heal) """ completions_by_skill = _completed_progress_by_skill(user) if not completions_by_skill: return [] skill_ids = list(completions_by_skill.keys()) user_skills = _user_skills_by_id(user, skill_ids) skills_by_id = { s.id: s for s in Skill.objects.filter(id__in=skill_ids) } suggestions: list[UpgradeSuggestion] = [] for skill_id, completions in completions_by_skill.items(): skill = skills_by_id.get(skill_id) if skill is None: continue us = user_skills.get(skill_id) current_prof = us.proficiency if us else 0 target = _suggested_proficiency(current_prof) if target is None or target <= current_prof: continue if us and us.dismissed_upgrade_at is not None: most_recent = max( (c['completed_at'] for c in completions if c['completed_at'] is not None), default=None, ) if most_recent is None or most_recent <= us.dismissed_upgrade_at: continue source_ids = sorted( {c['resource_id'] for c in completions}, ) suggestions.append(UpgradeSuggestion( skill_id=skill_id, skill_name=skill.skill_name, current_proficiency=current_prof, suggested_proficiency=target, current_level=proficiency_to_level(current_prof) if us else 'BEGINNER', suggested_level=proficiency_to_level(target), source_resource_ids=source_ids, )) suggestions.sort(key=lambda s: s.skill_name) return [s.to_dict() for s in suggestions] @transaction.atomic def apply_upgrade(user, skill_id: int) -> dict: """Apply the pending upgrade for the given skill. Returns {'applied': bool, 'user_skill': {...}, 'already_applied': bool}. Idempotent per completion: repeated calls without a new completion return already_applied=True. After a successful apply, dismissed_upgrade_at is stamped with the latest completion timestamp so `compute_upgrade_suggestions` (which already uses this field as a suppression cursor) won't re-surface the suggestion until a newer completion arrives. """ skill = Skill.objects.get(id=skill_id) completions = _completed_progress_by_skill(user).get(skill_id, []) if not completions: return { 'applied': False, 'already_applied': False, 'reason': 'no_completed_resource', } latest_completion = max( (c['completed_at'] for c in completions if c['completed_at'] is not None), default=None, ) us = UserSkill.objects.filter(user=user, skill=skill).first() current_prof = us.proficiency if us else 0 # Suppression gate: if the latest completion is not newer than the last # apply/dismiss cursor, there's nothing new to act on. if us and us.dismissed_upgrade_at is not None and latest_completion is not None: if latest_completion <= us.dismissed_upgrade_at: return { 'applied': False, 'already_applied': True, 'user_skill': _serialize_user_skill(us), } target = _suggested_proficiency(current_prof) if target is None or target <= current_prof: return { 'applied': False, 'already_applied': True, 'user_skill': _serialize_user_skill(us) if us else None, } new_level = proficiency_to_level(target) cursor = latest_completion or timezone.now() # update_or_create absorbs the first-apply race: two concurrent applies # with no prior UserSkill would otherwise collide on unique(user, skill) # → IntegrityError → 500. The earlier `us` read is kept for the suppression # gate; this is now the single write path for both create and update. us, _ = UserSkill.objects.update_or_create( user=user, skill=skill, defaults={ 'proficiency': target, 'user_level': new_level, 'dismissed_upgrade_at': cursor, }, ) return { 'applied': True, 'already_applied': False, 'user_skill': _serialize_user_skill(us), } @transaction.atomic def dismiss_upgrade(user, skill_id: int) -> dict: skill = Skill.objects.get(id=skill_id) us, _ = UserSkill.objects.get_or_create( user=user, skill=skill, defaults={'proficiency': 0, 'user_level': 'BEGINNER'}, ) us.dismissed_upgrade_at = timezone.now() us.save(update_fields=['dismissed_upgrade_at', 'updated_at']) return { 'dismissed': True, 'user_skill': _serialize_user_skill(us), } def _serialize_user_skill(us: UserSkill | None) -> dict | None: if us is None: return None return { 'skill_id': us.skill_id, 'skill_name': us.skill.skill_name, 'proficiency': us.proficiency, 'user_level': us.user_level, 'dismissed_upgrade_at': us.dismissed_upgrade_at, }