gapguide-api / apps /progress /services.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
8.29 kB
"""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,
}