Spaces:
Sleeping
Sleeping
| """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 | |
| 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] | |
| 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), | |
| } | |
| 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, | |
| } | |