Spaces:
Runtime error
Runtime error
File size: 8,285 Bytes
ffd36e0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | """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,
}
|