from django.conf import settings from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models from pgvector.django import HnswIndex, VectorField class Skill(models.Model): DIFFICULTY_CHOICES = [ ('BEGINNER', 'Beginner'), ('INTERMEDIATE', 'Intermediate'), ('ADVANCED', 'Advanced'), ] skill_name = models.CharField(max_length=255, unique=True) category = models.CharField(max_length=100) description = models.TextField(blank=True) difficulty_level = models.CharField( max_length=20, choices=DIFFICULTY_CHOICES, default='BEGINNER' ) def __str__(self): return self.skill_name class UserSkill(models.Model): DIFFICULTY_CHOICES = [ ('BEGINNER', 'Beginner'), ('INTERMEDIATE', 'Intermediate'), ('ADVANCED', 'Advanced'), ] user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='user_skills' ) skill = models.ForeignKey( Skill, on_delete=models.CASCADE, related_name='user_skills' ) proficiency = models.IntegerField( validators=[MinValueValidator(0), MaxValueValidator(100)] ) user_level = models.CharField(max_length=20, choices=DIFFICULTY_CHOICES) updated_at = models.DateTimeField(auto_now=True) # Cursor used by the upgrade-suggestion flow. Advanced on BOTH dismiss AND # apply (see apps.progress.services). A suggestion is eligible when the # latest related checkpoint completion is newer than this timestamp. # Name is historical — kept to avoid a field-rename migration. dismissed_upgrade_at = models.DateTimeField( null=True, blank=True, help_text=( "Last-action cursor for the upgrade-suggestion flow. Set on both " "dismiss and apply (see apps.progress.services.apply_upgrade and " "dismiss_upgrade). A new upgrade is eligible when any related " "resource completion is newer than this timestamp." ), ) class Meta: unique_together = [['user', 'skill']] def __str__(self): return f'{self.user.email} - {self.skill.skill_name}' class SkillEmbedding(models.Model): """SBERT embedding of a Skill, queried by Module 8 Layer 4 (SBERT canonical mapping) when higher-layer NER models fail to resolve a candidate span. 384 dims matches all-MiniLM-L6-v2; swapping models means re-running scripts/build_skill_embeddings.py against a fresh catalog. """ skill = models.OneToOneField( Skill, on_delete=models.CASCADE, related_name='embedding', ) embedding = VectorField(dimensions=384) source_text = models.TextField() model_name = models.CharField(max_length=100, default='all-MiniLM-L6-v2') updated_at = models.DateTimeField(auto_now=True) class Meta: indexes = [ HnswIndex( name='skillemb_hnsw_idx', fields=['embedding'], m=16, ef_construction=64, opclasses=['vector_cosine_ops'], ), ] def __str__(self): return f'Embedding({self.skill.skill_name})'