arifRB commited on
Commit
ffd36e0
·
verified ·
1 Parent(s): e95d892

Deploy GapGuide backend (Docker)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +11 -0
  2. .env.example +33 -0
  3. .gitattributes +1 -35
  4. .ruff_cache/.gitignore +2 -0
  5. .ruff_cache/0.15.9/14848081398162790867 +0 -0
  6. .ruff_cache/0.15.9/397764829197027149 +0 -0
  7. .ruff_cache/0.15.9/4097662610744648579 +0 -0
  8. .ruff_cache/0.15.9/5949281871524638798 +0 -0
  9. .ruff_cache/0.15.9/6974492653149135859 +0 -0
  10. .ruff_cache/CACHEDIR.TAG +1 -0
  11. Dockerfile +43 -0
  12. INSTALL.md +118 -0
  13. README.md +13 -5
  14. apps/__init__.py +0 -0
  15. apps/accounts/__init__.py +0 -0
  16. apps/accounts/admin.py +26 -0
  17. apps/accounts/apps.py +6 -0
  18. apps/accounts/management/__init__.py +0 -0
  19. apps/accounts/management/commands/__init__.py +0 -0
  20. apps/accounts/management/commands/seed_demo_users.py +290 -0
  21. apps/accounts/migrations/0001_initial.py +46 -0
  22. apps/accounts/migrations/0002_user_created_at_alter_user_role_type.py +25 -0
  23. apps/accounts/migrations/0003_userprofile.py +28 -0
  24. apps/accounts/migrations/__init__.py +0 -0
  25. apps/accounts/models.py +41 -0
  26. apps/accounts/ner/__init__.py +57 -0
  27. apps/accounts/ner/jobbert.py +117 -0
  28. apps/accounts/ner/lexical.py +42 -0
  29. apps/accounts/ner/nucha.py +76 -0
  30. apps/accounts/ner/sbert.py +131 -0
  31. apps/accounts/ner/skillner.py +95 -0
  32. apps/accounts/resume_parser.py +456 -0
  33. apps/accounts/serializers.py +112 -0
  34. apps/accounts/tests/__init__.py +0 -0
  35. apps/accounts/tests/test_auth.py +140 -0
  36. apps/accounts/tests/test_ner_dispatcher.py +196 -0
  37. apps/accounts/tests/test_ner_layers.py +221 -0
  38. apps/accounts/tests/test_profile.py +152 -0
  39. apps/accounts/tests/test_resume_parser.py +254 -0
  40. apps/accounts/tests/test_resume_parser_integration.py +102 -0
  41. apps/accounts/urls.py +16 -0
  42. apps/accounts/views.py +293 -0
  43. apps/analysis/__init__.py +0 -0
  44. apps/analysis/admin.py +1 -0
  45. apps/analysis/apps.py +6 -0
  46. apps/analysis/models.py +1 -0
  47. apps/analysis/services.py +479 -0
  48. apps/analysis/tests/__init__.py +0 -0
  49. apps/analysis/tests/test_gap_analysis.py +280 -0
  50. apps/analysis/tests/test_recommendations.py +334 -0
.dockerignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ **/__pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ logs/
6
+ *.log
7
+ seed_data/onet_dump/
8
+ **/tests/
9
+ .git/
10
+ .env
11
+ staticfiles/
.env.example ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SECRET_KEY=your-super-secret-key-change-in-production
2
+ DEBUG=True
3
+ DB_NAME=gapguide_db
4
+ DB_USER=postgres
5
+ DB_PASSWORD=postgres
6
+ DB_HOST=localhost
7
+ DB_PORT=5432
8
+ ALLOWED_HOSTS=localhost,127.0.0.1
9
+ CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
10
+
11
+ # Optional. Enables the "Import modules from URL" admin action to read chapter
12
+ # markers from single YouTube videos via the Data API v3 (free, 10k units/day).
13
+ # Leave unset and YouTube imports fall back to manual checkpoint entry.
14
+ # YOUTUBE_API_KEY=
15
+
16
+ # Prevent __pycache__/*.pyc files from shadowing edited source during dev.
17
+ PYTHONDONTWRITEBYTECODE=1
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Production / deployment only (Hugging Face Space secrets). Leave ALL of these
21
+ # UNSET locally — when unset the app uses the local DB_* + DEBUG=True defaults
22
+ # above. Do NOT put these in a local .env.
23
+ # ---------------------------------------------------------------------------
24
+ # DEBUG=False
25
+ # SECRET_KEY=<a long random secret>
26
+ # ALLOWED_HOSTS=*
27
+ # DATABASE_URL=postgresql://USER:PASSWORD@HOST/DB?sslmode=require # Neon DIRECT (non-pooled) URL
28
+ # CORS_ALLOWED_ORIGINS=https://<your-project>.vercel.app
29
+ # CSRF_TRUSTED_ORIGINS=https://<your-space>.hf.space
30
+ # SECURE_SSL_REDIRECT=False # HF terminates HTTPS at its edge
31
+ # DEMO_ADMIN_PASSWORD=<private admin password for the public deploy>
32
+ # BUILD_EMBEDDINGS=1 # first boot only: build SBERT skill embeddings
33
+ # THROTTLE_RESUME=200/hour # optional: raise the resume-parse rate for a demo
.gitattributes CHANGED
@@ -1,35 +1 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.sh text eol=lf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.ruff_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Automatically created by ruff.
2
+ *
.ruff_cache/0.15.9/14848081398162790867 ADDED
Binary file (279 Bytes). View file
 
.ruff_cache/0.15.9/397764829197027149 ADDED
Binary file (109 Bytes). View file
 
.ruff_cache/0.15.9/4097662610744648579 ADDED
Binary file (236 Bytes). View file
 
.ruff_cache/0.15.9/5949281871524638798 ADDED
Binary file (224 Bytes). View file
 
.ruff_cache/0.15.9/6974492653149135859 ADDED
Binary file (5.08 kB). View file
 
.ruff_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1 @@
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
Dockerfile ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GapGuide backend — Hugging Face Docker Space image.
2
+ # Django + DRF API with the in-process NER chain, served by gunicorn on :7860.
3
+ FROM python:3.13-slim
4
+
5
+ ENV PYTHONUNBUFFERED=1 \
6
+ PYTHONDONTWRITEBYTECODE=1 \
7
+ PIP_NO_CACHE_DIR=1 \
8
+ HF_HOME=/app/.cache/huggingface \
9
+ NLTK_DATA=/app/.cache/nltk
10
+
11
+ # HF Spaces run the container as UID 1000.
12
+ RUN useradd -m -u 1000 appuser
13
+ WORKDIR /app
14
+
15
+ # Python deps first for better layer caching. requirements.txt carries the
16
+ # torch CPU --extra-index-url, so CPU wheels resolve here (verified: torch
17
+ # 2.11/2.12 +cpu cp313 manylinux exist).
18
+ COPY requirements.txt .
19
+ RUN pip install --no-cache-dir -r requirements.txt \
20
+ && python -m spacy download en_core_web_sm
21
+
22
+ # App code, owned by the runtime user in a single COPY (no recursive-chown bloat).
23
+ COPY --chown=1000:1000 . .
24
+
25
+ # Make the dirs the next steps write to owned by UID 1000 (the /app dir itself
26
+ # is root-owned from WORKDIR; chown only the specific dirs, not the whole tree).
27
+ RUN mkdir -p /app/.cache/huggingface /app/.cache/nltk /app/staticfiles \
28
+ && chown 1000:1000 /app /app/.cache /app/.cache/huggingface /app/.cache/nltk /app/staticfiles
29
+
30
+ USER 1000
31
+
32
+ # Bake everything the NER chain downloads → fast, offline-safe first parse in prod.
33
+ # Runs as UID 1000 so caches land under the owned /app/.cache.
34
+ RUN python -m nltk.downloader -d /app/.cache/nltk stopwords punkt punkt_tab \
35
+ && python scripts/prefetch_models.py
36
+
37
+ # Collect Django admin / DRF static for WhiteNoise. Inline build env sidesteps the
38
+ # DEBUG=False "insecure SECRET_KEY" guard; it does not persist to runtime.
39
+ RUN DEBUG=True SECRET_KEY=build-only python manage.py collectstatic --noinput
40
+
41
+ EXPOSE 7860
42
+ # /bin/sh form survives a Windows upload (no exec-bit / shebang dependency).
43
+ ENTRYPOINT ["/bin/sh", "entrypoint.sh"]
INSTALL.md ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local Setup — GapGuide Backend
2
+
3
+ Prerequisites: Windows 10/11, PostgreSQL 17 installed, Python 3.13 (already set up at `backend/venv/`), Node 20+ for the frontend.
4
+
5
+ ## 1. Install pgvector extension on PostgreSQL 17
6
+
7
+ Module 8 Layer 4 uses `pgvector` for SBERT cosine similarity. `CREATE EXTENSION vector` fails until the extension files are installed into the PostgreSQL server directory. Two options:
8
+
9
+ ### Option A — Precompiled binary (recommended, ~2 minutes)
10
+
11
+ 1. Go to https://github.com/andreiramani/pgvector_pgsql_windows/releases
12
+ 2. Download the release tagged `0.8.2_17.6` (built against PostgreSQL 17.6; works on 17.3+ — older 17.0–17.2 hit a linker bug).
13
+ 3. Extract. You should see `vector.control`, `vector--*.sql`, and `vector.dll`.
14
+ 4. Copy files:
15
+ - `vector.control` and `vector--*.sql` → `C:\Program Files\PostgreSQL\17\share\extension\`
16
+ - `vector.dll` → `C:\Program Files\PostgreSQL\17\lib\`
17
+ - Administrator rights required (Program Files is protected).
18
+ 5. Verify in psql:
19
+ ```
20
+ psql -U postgres -d gapguide -c "CREATE EXTENSION IF NOT EXISTS vector; SELECT '[1,2,3]'::vector;"
21
+ ```
22
+ Output should include `[1,2,3]`.
23
+
24
+ ### Option B — Build from source (~30 min, no trusted binary)
25
+
26
+ 1. Install Visual Studio with "Desktop development with C++".
27
+ 2. Open "x64 Native Tools Command Prompt for VS" as **administrator**.
28
+ 3. Run:
29
+ ```
30
+ set "PGROOT=C:\Program Files\PostgreSQL\17"
31
+ cd %TEMP%
32
+ git clone --branch v0.8.2 https://github.com/pgvector/pgvector.git
33
+ cd pgvector
34
+ nmake /F Makefile.win
35
+ nmake /F Makefile.win install
36
+ ```
37
+ 4. Verify as in Option A step 5.
38
+
39
+ ## 2. Python dependencies
40
+
41
+ ```powershell
42
+ cd backend
43
+ .\venv\Scripts\Activate.ps1
44
+ pip install -r requirements.txt
45
+ python -m spacy download en_core_web_sm
46
+ ```
47
+
48
+ First install is ~5 minutes (torch CPU wheel is the heaviest at ~200 MB).
49
+
50
+ The NER layers will use `en_core_web_sm` (12 MB) by default. If you want
51
+ slightly better noun-phrase quality on paraphrased CVs, upgrade to
52
+ `en_core_web_lg` (~560 MB) — the layers auto-detect and prefer `_lg`:
53
+
54
+ ```powershell
55
+ python -m spacy download en_core_web_lg
56
+ ```
57
+
58
+ HuggingFace models download lazily on first parse into `%USERPROFILE%\.cache\huggingface` (~1 GB total across Nucha BERT, JobBERT, SBERT). Subsequent parses are instant.
59
+
60
+ ## 3. Database setup
61
+
62
+ ```powershell
63
+ # create the DB (if fresh)
64
+ createdb -U postgres gapguide
65
+
66
+ # migrate (requires pgvector from step 1)
67
+ python manage.py migrate
68
+
69
+ # seed the catalog + 5 demo users
70
+ python manage.py seed_initial_skills
71
+ python manage.py seed_initial_roles
72
+ python manage.py seed_initial_resources
73
+ python manage.py seed_demo_users
74
+
75
+ # build SBERT embeddings for the Skill catalog (Module 8 Layer 4)
76
+ python scripts/build_skill_embeddings.py
77
+ ```
78
+
79
+ Demo user login: `demo.partial@gapguide.test` / `DemoPass123!`.
80
+
81
+ ## 4. Run locally
82
+
83
+ ```powershell
84
+ # terminal 1 — backend
85
+ cd backend
86
+ .\venv\Scripts\Activate.ps1
87
+ python manage.py runserver
88
+
89
+ # terminal 2 — frontend
90
+ cd frontend
91
+ npm install
92
+ npm run dev
93
+ ```
94
+
95
+ Open http://localhost:8080.
96
+
97
+ ## 5. Running tests
98
+
99
+ ```powershell
100
+ cd backend
101
+
102
+ # CI baseline (no ML, seconds)
103
+ $env:GAPGUIDE_PARSE_LAYERS = "lexical"
104
+ pytest -q
105
+
106
+ # ML smoke test (downloads models on first run — ~15 min first time)
107
+ Remove-Item Env:\GAPGUIDE_PARSE_LAYERS
108
+ $env:GAPGUIDE_ML_SMOKE = "1"
109
+ pytest apps/accounts/tests/test_resume_parser_integration.py -q
110
+ ```
111
+
112
+ ## Troubleshooting
113
+
114
+ - **`extension "vector" is not available`** — pgvector not installed on your Postgres. Redo step 1. The `vector.control` file must exist in PostgreSQL's `share\extension\` directory.
115
+ - **`ModuleNotFoundError: pgvector`** — `pip install -r requirements.txt` hasn't run (or ran in a different venv). Activate the venv first.
116
+ - **`spaCy model not found`** — run `python -m spacy download en_core_web_lg`.
117
+ - **Slow first resume parse** — normal. The NER chain downloads ~1 GB of models on first call. Subsequent parses use the cache.
118
+ - **`GAPGUIDE_PARSE_LAYERS=lexical`** — lets you run the backend without any ML deps loaded. Useful during dev when you don't want the first-call model downloads.
README.md CHANGED
@@ -1,10 +1,18 @@
1
  ---
2
- title: Gapguide Api
3
- emoji: 👀
4
- colorFrom: red
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
1
  ---
2
+ title: GapGuide API
3
+ emoji: 🎯
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # GapGuide API
12
+
13
+ Django + DRF backend for **GapGuide** (AI Job Fit & Skill Gap Assistant), with the
14
+ in-process resume-parsing NER chain. Deployed as a Hugging Face Docker Space; the
15
+ React frontend is hosted separately on Vercel and talks to this API under `/api`.
16
+
17
+ The container runs database migrations and idempotent catalog/demo seeds on start,
18
+ then serves the API with gunicorn on port 7860. See the repository for full docs.
apps/__init__.py ADDED
File without changes
apps/accounts/__init__.py ADDED
File without changes
apps/accounts/admin.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.contrib import admin
2
+ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
3
+ from .models import User
4
+
5
+
6
+ @admin.register(User)
7
+ class UserAdmin(BaseUserAdmin):
8
+ list_display = ['email', 'name', 'role_type', 'is_active', 'created_at']
9
+ list_filter = ['role_type', 'is_active', 'is_staff']
10
+ search_fields = ['email', 'name']
11
+ ordering = ['-created_at']
12
+ readonly_fields = ['created_at']
13
+
14
+ fieldsets = (
15
+ (None, {'fields': ('email', 'password')}),
16
+ ('Personal info', {'fields': ('name', 'role_type')}),
17
+ ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
18
+ ('Important dates', {'fields': ('last_login', 'created_at')}),
19
+ )
20
+
21
+ add_fieldsets = (
22
+ (None, {
23
+ 'classes': ('wide',),
24
+ 'fields': ('email', 'password1', 'password2', 'name', 'role_type'),
25
+ }),
26
+ )
apps/accounts/apps.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class AccountsConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'apps.accounts'
apps/accounts/management/__init__.py ADDED
File without changes
apps/accounts/management/commands/__init__.py ADDED
File without changes
apps/accounts/management/commands/seed_demo_users.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed 5 demo users spanning the WEAK, PARTIAL, and STRONG readiness bands plus the closed loop.
2
+
3
+ Idempotent: re-running the command does not duplicate users or create
4
+ stacked UserProgress rows. Designed for local demo + manual QA.
5
+
6
+ Personas:
7
+ 1. demo.weak — Data Scientist, Python=30 only. WEAK band.
8
+ 2. demo.partial — Data Scientist, Python=80 SQL=90 Scikit-learn=0. PARTIAL + cap.
9
+ 3. demo.ready — Frontend Developer, skills ≥ INTERMEDIATE. STRONG band.
10
+ 4. demo.closedloop — ML Engineer, one COMPLETED resource → upgrade pending.
11
+ 5. demo.admin — superuser, for Module 7 RoleAdmin demos.
12
+
13
+ Password for every persona: DemoPass123! (printed in command output).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ from django.contrib.auth import get_user_model
20
+ from django.core.management.base import BaseCommand
21
+ from django.db import transaction
22
+ from django.utils import timezone
23
+
24
+ from apps.accounts.models import UserProfile
25
+ from apps.progress.models import UserProgress
26
+ from apps.resources.models import Resource, SkillResource
27
+ from apps.roles.models import Role, UserTargetRole
28
+ from apps.skills.models import Skill, UserSkill
29
+ from apps.skills.utils import proficiency_to_level
30
+
31
+
32
+ DEMO_PASSWORD = 'DemoPass123!'
33
+ # On a public deployment the superuser must NOT carry the well-known demo
34
+ # password. Override the admin password via the DEMO_ADMIN_PASSWORD env var;
35
+ # falls back to DEMO_PASSWORD locally where the env var is unset.
36
+ ADMIN_PASSWORD = os.environ.get('DEMO_ADMIN_PASSWORD', DEMO_PASSWORD)
37
+
38
+
39
+ # (email, name, target_role_name, {skill_name: proficiency}, degree, semester)
40
+ # target_role_name=None skips target setup (only persona 5/admin).
41
+ PERSONAS: list[dict] = [
42
+ {
43
+ 'email': 'demo.weak@gapguide.test',
44
+ 'name': 'Demo Weak',
45
+ 'target': 'Data Scientist',
46
+ 'skills': {'Python': 30},
47
+ 'degree': 'BSDS',
48
+ 'semester': 4,
49
+ },
50
+ {
51
+ 'email': 'demo.partial@gapguide.test',
52
+ 'name': 'Demo Partial',
53
+ 'target': 'Data Scientist',
54
+ # Most Data Scientist skills at MET levels so raw readiness lands
55
+ # well above the 60 mandatory-cap. The single MISSING mandatory
56
+ # skill (Scikit-learn) is what pulls the score down to the cap —
57
+ # exercising the "Partial Match" band + mandatory_cap_applied path.
58
+ # Strong coverage across the Data Scientist skill set with ONE
59
+ # mandatory miss (Scikit-learn=0). Raw readiness lands comfortably
60
+ # above the 60 cap so the mandatory-cap logic actually fires and
61
+ # mandatory_cap_applied=True becomes visible in the demo.
62
+ 'skills': {
63
+ 'Python': 80,
64
+ 'SQL': 90,
65
+ 'Pandas': 75,
66
+ 'NumPy': 75,
67
+ 'PyTorch': 70,
68
+ 'Statistical Hypothesis Testing': 70,
69
+ 'Git': 85,
70
+ 'Scikit-learn': 0, # MISSING + mandatory -> triggers cap
71
+ 'Apache Spark': 60,
72
+ 'AWS': 60,
73
+ 'Docker': 55,
74
+ 'Feature Engineering': 70,
75
+ 'Matplotlib': 70,
76
+ 'Microsoft Power BI': 50,
77
+ 'Tableau': 65,
78
+ 'TensorFlow': 55,
79
+ },
80
+ 'degree': 'BSDS',
81
+ 'semester': 6,
82
+ },
83
+ {
84
+ 'email': 'demo.ready@gapguide.test',
85
+ 'name': 'Demo Ready',
86
+ 'target': 'Frontend Developer',
87
+ # Filled in dynamically below from Frontend Developer's RoleSkills.
88
+ 'skills': None,
89
+ 'degree': 'BSCS',
90
+ 'semester': 7,
91
+ },
92
+ {
93
+ 'email': 'demo.closedloop@gapguide.test',
94
+ 'name': 'Demo ClosedLoop',
95
+ 'target': 'Machine Learning Engineer',
96
+ # Mid-stage ML learner: most core skills MET, a few shortfalls, and
97
+ # Python sits at 60 so the COMPLETED Python resource triggers a
98
+ # "bump 60 -> 85" upgrade suggestion on the Progress page.
99
+ 'skills': {
100
+ 'Python': 60,
101
+ 'SQL': 70,
102
+ 'NumPy': 65,
103
+ 'Pandas': 60,
104
+ 'Scikit-learn': 55,
105
+ 'PyTorch': 50,
106
+ 'TensorFlow': 45,
107
+ 'Git': 75,
108
+ 'GitHub': 70,
109
+ 'Docker': 50,
110
+ 'AWS': 40,
111
+ },
112
+ 'degree': 'BSDS',
113
+ 'semester': 8,
114
+ },
115
+ {
116
+ 'email': 'demo.admin@gapguide.test',
117
+ 'name': 'Demo Admin',
118
+ 'target': None,
119
+ 'skills': {},
120
+ 'is_staff': True,
121
+ 'is_superuser': True,
122
+ 'role_type': 'ADMIN',
123
+ },
124
+ ]
125
+
126
+
127
+ class Command(BaseCommand):
128
+ help = (
129
+ "Create or refresh 5 demo users (weak / partial / ready / closedloop / "
130
+ "admin) spanning the WEAK/PARTIAL/STRONG readiness bands and the "
131
+ "skill-upgrade loop. Idempotent; re-run after seed catalog changes."
132
+ )
133
+
134
+ def handle(self, *args, **options):
135
+ User = get_user_model()
136
+
137
+ created_count = 0
138
+ updated_count = 0
139
+ warnings: list[str] = []
140
+
141
+ for spec in PERSONAS:
142
+ with transaction.atomic():
143
+ user, created = User.objects.update_or_create(
144
+ email=spec['email'],
145
+ defaults={
146
+ 'username': spec['email'],
147
+ 'name': spec['name'],
148
+ 'role_type': spec.get('role_type', 'STUDENT'),
149
+ 'is_staff': spec.get('is_staff', False),
150
+ 'is_superuser': spec.get('is_superuser', False),
151
+ },
152
+ )
153
+ pwd = ADMIN_PASSWORD if spec.get('is_superuser') else DEMO_PASSWORD
154
+ user.set_password(pwd)
155
+ user.save(update_fields=['password'])
156
+ if created:
157
+ created_count += 1
158
+ else:
159
+ updated_count += 1
160
+
161
+ UserProfile.objects.update_or_create(
162
+ user=user,
163
+ defaults={
164
+ 'degree': spec.get('degree', ''),
165
+ 'semester': spec.get('semester'),
166
+ 'interests': '',
167
+ 'bio': f"Demo persona: {spec['name']}.",
168
+ },
169
+ )
170
+
171
+ target_name = spec.get('target')
172
+ if target_name:
173
+ role = Role.objects.filter(role_name=target_name).first()
174
+ if role is None:
175
+ warnings.append(
176
+ f"{spec['email']}: target role '{target_name}' not in catalog "
177
+ f"(did you run seed_initial_roles?). Skipping target set."
178
+ )
179
+ else:
180
+ UserTargetRole.objects.filter(user=user).delete()
181
+ UserTargetRole.objects.create(
182
+ user=user, role=role, is_active=True,
183
+ )
184
+
185
+ skills_spec = spec['skills']
186
+ if skills_spec is None:
187
+ # demo.ready persona: build from the target role's required skills
188
+ skills_spec = self._ready_persona_skills(target_name, warnings, spec['email'])
189
+
190
+ for skill_name, proficiency in (skills_spec or {}).items():
191
+ skill = Skill.objects.filter(skill_name=skill_name).first()
192
+ if skill is None:
193
+ warnings.append(
194
+ f"{spec['email']}: skill '{skill_name}' not in catalog. Skipping."
195
+ )
196
+ continue
197
+ UserSkill.objects.update_or_create(
198
+ user=user, skill=skill,
199
+ defaults={
200
+ 'proficiency': proficiency,
201
+ 'user_level': proficiency_to_level(proficiency),
202
+ 'dismissed_upgrade_at': None,
203
+ },
204
+ )
205
+
206
+ # Persona 4: mark one Python-linked resource COMPLETED so the
207
+ # upgrade-suggestion engine has a reason to fire.
208
+ if spec['email'].startswith('demo.closedloop@'):
209
+ self._seed_closed_loop_completion(user, warnings)
210
+
211
+ # Summary output.
212
+ self.stdout.write(self.style.SUCCESS(
213
+ f"Demo users: {created_count} created, {updated_count} refreshed "
214
+ f"(password for all: {DEMO_PASSWORD})"
215
+ ))
216
+ for line in warnings:
217
+ self.stdout.write(self.style.WARNING(' ! ' + line))
218
+ self.stdout.write("")
219
+ for spec in PERSONAS:
220
+ self.stdout.write(
221
+ f" {spec['email']:<40} -> {spec.get('target') or '(admin)'}"
222
+ )
223
+
224
+ @staticmethod
225
+ def _ready_persona_skills(
226
+ target_name: str | None, warnings: list, email: str,
227
+ ) -> dict[str, int]:
228
+ """demo.ready has every required skill comfortably above its threshold.
229
+
230
+ INTERMEDIATE threshold=60, ADVANCED threshold=100. We set proficiency
231
+ 80 for INTERMEDIATE skills (well above threshold → fully satisfied) and
232
+ 100 for ADVANCED skills, so every skill is MET → readiness 100 (STRONG).
233
+ This persona has no completed resource, so no upgrade suggestion fires.
234
+ """
235
+ if not target_name:
236
+ return {}
237
+ role = Role.objects.filter(role_name=target_name).first()
238
+ if role is None:
239
+ warnings.append(
240
+ f"{email}: cannot populate skills — target role '{target_name}' missing."
241
+ )
242
+ return {}
243
+ skills: dict[str, int] = {}
244
+ for rs in role.role_skills.select_related('skill'):
245
+ if rs.required_level == 'ADVANCED':
246
+ skills[rs.skill.skill_name] = 100
247
+ elif rs.required_level == 'INTERMEDIATE':
248
+ skills[rs.skill.skill_name] = 80
249
+ else: # BEGINNER
250
+ skills[rs.skill.skill_name] = 55
251
+ return skills
252
+
253
+ @staticmethod
254
+ def _seed_closed_loop_completion(user, warnings: list) -> None:
255
+ """Mark one Python-linked Resource as COMPLETED for the closedloop user.
256
+
257
+ Given Python=60 on the user, the upgrade engine will suggest 85 when
258
+ it sees a completed Python resource. This makes the "Upgrade pending"
259
+ Alert card (Progress.jsx:146) appear in the demo without any clicks.
260
+ """
261
+ python = Skill.objects.filter(skill_name='Python').first()
262
+ if python is None:
263
+ warnings.append(
264
+ "closedloop: Python skill missing from catalog — skipping closed-loop seed."
265
+ )
266
+ return
267
+
268
+ resource = (
269
+ Resource.objects
270
+ .filter(skills=python)
271
+ .order_by('id')
272
+ .first()
273
+ )
274
+ if resource is None:
275
+ warnings.append(
276
+ "closedloop: no Resource links to Python — skipping closed-loop seed "
277
+ "(did you run seed_initial_resources?)."
278
+ )
279
+ return
280
+
281
+ now = timezone.now()
282
+ UserProgress.objects.update_or_create(
283
+ user=user, resource=resource,
284
+ defaults={
285
+ 'status': 'COMPLETED',
286
+ 'progress': 100,
287
+ 'started_at': now,
288
+ 'completed_at': now,
289
+ },
290
+ )
apps/accounts/migrations/0001_initial.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 6.0.4 on 2026-04-07 18:50
2
+
3
+ import django.contrib.auth.models
4
+ import django.contrib.auth.validators
5
+ import django.utils.timezone
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ initial = True
12
+
13
+ dependencies = [
14
+ ('auth', '0012_alter_user_first_name_max_length'),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name='User',
20
+ fields=[
21
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+ ('password', models.CharField(max_length=128, verbose_name='password')),
23
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
24
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
25
+ ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
26
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
27
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
28
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
29
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
30
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
31
+ ('name', models.CharField(blank=True, max_length=255)),
32
+ ('email', models.EmailField(max_length=254, unique=True)),
33
+ ('role_type', models.CharField(choices=[('STUDENT', 'Student'), ('ADMIN', 'Admin')], default='STUDENT', max_length=10)),
34
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
35
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
36
+ ],
37
+ options={
38
+ 'verbose_name': 'user',
39
+ 'verbose_name_plural': 'users',
40
+ 'abstract': False,
41
+ },
42
+ managers=[
43
+ ('objects', django.contrib.auth.models.UserManager()),
44
+ ],
45
+ ),
46
+ ]
apps/accounts/migrations/0002_user_created_at_alter_user_role_type.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 6.0.4 on 2026-04-07 19:28
2
+
3
+ import django.utils.timezone
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('accounts', '0001_initial'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name='user',
16
+ name='created_at',
17
+ field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
18
+ preserve_default=False,
19
+ ),
20
+ migrations.AlterField(
21
+ model_name='user',
22
+ name='role_type',
23
+ field=models.CharField(choices=[('STUDENT', 'Student'), ('ADMIN', 'Admin')], default='STUDENT', max_length=20),
24
+ ),
25
+ ]
apps/accounts/migrations/0003_userprofile.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 6.0.4 on 2026-04-07 19:34
2
+
3
+ import django.core.validators
4
+ import django.db.models.deletion
5
+ from django.conf import settings
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ dependencies = [
12
+ ('accounts', '0002_user_created_at_alter_user_role_type'),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name='UserProfile',
18
+ fields=[
19
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20
+ ('degree', models.CharField(blank=True, max_length=255)),
21
+ ('semester', models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(12)])),
22
+ ('interests', models.TextField(blank=True)),
23
+ ('bio', models.TextField(blank=True)),
24
+ ('updated_at', models.DateTimeField(auto_now=True)),
25
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
26
+ ],
27
+ ),
28
+ ]
apps/accounts/migrations/__init__.py ADDED
File without changes
apps/accounts/models.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.conf import settings
2
+ from django.contrib.auth.models import AbstractUser
3
+ from django.core.validators import MinValueValidator, MaxValueValidator
4
+ from django.db import models
5
+
6
+
7
+ class User(AbstractUser):
8
+ ROLE_CHOICES = [
9
+ ('STUDENT', 'Student'),
10
+ ('ADMIN', 'Admin'),
11
+ ]
12
+
13
+ name = models.CharField(max_length=255, blank=True)
14
+ email = models.EmailField(unique=True)
15
+ role_type = models.CharField(max_length=20, choices=ROLE_CHOICES, default='STUDENT')
16
+ created_at = models.DateTimeField(auto_now_add=True)
17
+
18
+ USERNAME_FIELD = 'email'
19
+ REQUIRED_FIELDS = ['username', 'name']
20
+
21
+ def __str__(self):
22
+ return self.email
23
+
24
+
25
+ class UserProfile(models.Model):
26
+ user = models.OneToOneField(
27
+ settings.AUTH_USER_MODEL,
28
+ on_delete=models.CASCADE,
29
+ related_name='profile'
30
+ )
31
+ degree = models.CharField(max_length=255, blank=True)
32
+ semester = models.IntegerField(
33
+ null=True, blank=True,
34
+ validators=[MinValueValidator(1), MaxValueValidator(12)]
35
+ )
36
+ interests = models.TextField(blank=True)
37
+ bio = models.TextField(blank=True)
38
+ updated_at = models.DateTimeField(auto_now=True)
39
+
40
+ def __str__(self):
41
+ return f'Profile of {self.user.email}'
apps/accounts/ner/__init__.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NER layer package for Module 8 resume parsing.
2
+
3
+ Layers (merge-all semantics — see research/06-dataset-sourcing.md §5 and
4
+ the plan at ~/.claude/plans/okay-plan-for-7a-fluffy-marshmallow.md):
5
+
6
+ 1. nucha — Nucha/Nucha_ITSkillNER_BERT (MIT, F1 0.90)
7
+ 2. jobbert — jjzha/jobbert_skill_extraction (license unspecified, FYP-OK)
8
+ 3. skillner — rule-based, EMSI taxonomy (Nov 2021 release, still works)
9
+ 4. sbert — SentenceTransformer('all-MiniLM-L6-v2') + pgvector cosine
10
+
11
+ Each concrete layer implements ``NerLayer`` and exposes a module-level
12
+ lazy singleton so the HF models only load on first call. The dispatcher in
13
+ ``resume_parser._predict_skills_from_text`` invokes the layers in order,
14
+ catches any import / timeout / model-download error per layer, and unions
15
+ the results into one {alias: max_confidence} dict.
16
+
17
+ The lexical floor (``ner.lexical``) is always run last even if the higher
18
+ layers succeed — it guarantees the endpoint returns at least the catalog
19
+ matches regardless of ML-layer availability.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from abc import ABC, abstractmethod
24
+
25
+
26
+ class NerLayer(ABC):
27
+ """Contract every NER layer must satisfy.
28
+
29
+ Implementations should:
30
+ * Keep the model as a module-level singleton, loaded on first call.
31
+ * Catch *internal* model errors and return an empty dict rather than
32
+ raising — the dispatcher already wraps calls in try/except, but
33
+ returning {} for recoverable failures keeps the log quieter.
34
+ * Never raise for an empty or non-English input — just return {}.
35
+ """
36
+
37
+ #: Short stable identifier used in the response's ``parser_version`` list.
38
+ #: Keep lowercase, no spaces.
39
+ name: str = "abstract"
40
+
41
+ @abstractmethod
42
+ def predict(self, text: str) -> dict[str, float]:
43
+ """Return {skill_or_alias_name: confidence_0_to_1}.
44
+
45
+ Keys are free-text — the dispatcher runs alias resolution against the
46
+ Skill catalog before they reach the user. Values are [0, 1] floats.
47
+ """
48
+ raise NotImplementedError
49
+
50
+ def available(self) -> bool:
51
+ """Cheap check the dispatcher calls before invoking predict().
52
+
53
+ Default: attempt a lazy-import of the layer's heavy deps. Override
54
+ for layers whose availability depends on external state (e.g. a
55
+ model being cached locally).
56
+ """
57
+ return True
apps/accounts/ner/jobbert.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer 2: jjzha/jobbert_skill_extraction — SkillSpan-trained BERT NER.
2
+
3
+ Model: https://huggingface.co/jjzha/jobbert_skill_extraction
4
+ - 107.7M params, BERT, token-classification, B-Skill/I-Skill labels
5
+ - License is NOT stated on the model card — OK for academic FYP use only.
6
+ See research/06-dataset-sourcing.md §5.
7
+ - Downloads ~440 MB on first use.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ from . import NerLayer
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _MODEL_ID = "jjzha/jobbert_skill_extraction"
19
+ _pipeline = None
20
+ _warned_license = False
21
+
22
+
23
+ def _get_pipeline():
24
+ global _pipeline, _warned_license
25
+ if _pipeline is None:
26
+ from transformers import pipeline
27
+ if not _warned_license:
28
+ logger.info(
29
+ "jobbert: %s has no stated license; use restricted to "
30
+ "academic/FYP scope.", _MODEL_ID,
31
+ )
32
+ _warned_license = True
33
+ logger.info("jobbert: loading %s pipeline (first call may be slow)", _MODEL_ID)
34
+ # Do NOT pass truncation/max_length to the token-classification
35
+ # pipeline() constructor — its `_sanitize_parameters` rejects them with
36
+ # a TypeError, which made this whole layer fail to construct and get
37
+ # silently skipped by the dispatcher. HF auto-truncates in preprocess,
38
+ # and predict() already slices the input to 4000 chars.
39
+ _pipeline = pipeline(
40
+ task="ner",
41
+ model=_MODEL_ID,
42
+ aggregation_strategy="simple",
43
+ )
44
+ return _pipeline
45
+
46
+
47
+ class JobBertLayer(NerLayer):
48
+ name = "jobbert"
49
+
50
+ def predict(self, text: str) -> dict[str, float]:
51
+ if not text.strip():
52
+ return {}
53
+ pipe = _get_pipeline()
54
+ src = text[:4000]
55
+ spans = pipe(src)
56
+
57
+ out: dict[str, float] = {}
58
+
59
+ def _emit(term: str, conf: float) -> None:
60
+ term = term.strip(" ,.;:-")
61
+ if len(term) >= 2 and conf > out.get(term, 0.0):
62
+ out[term] = conf
63
+
64
+ # Accumulator for re-stitching a multi-token skill from its char offsets.
65
+ cur_start = None
66
+ cur_end = 0
67
+ cur_conf = 0.0
68
+
69
+ def _flush() -> None:
70
+ nonlocal cur_start, cur_end, cur_conf
71
+ if cur_start is not None:
72
+ _emit(src[cur_start:cur_end], cur_conf)
73
+ cur_start, cur_end, cur_conf = None, 0, 0.0
74
+
75
+ for span in spans:
76
+ # jjzha/jobbert_skill_extraction has exactly one entity type
77
+ # (SKILL). Under aggregation_strategy="simple", HuggingFace emits
78
+ # entity_group as the bare BIO prefix ("B" / "I"), so any non-O span
79
+ # is a skill span.
80
+ label = (span.get("entity_group") or span.get("entity") or "").upper()
81
+ if label in ("", "O"):
82
+ _flush()
83
+ continue
84
+ conf = float(span.get("score") or 0.0)
85
+ start, end = span.get("start"), span.get("end")
86
+ if start is None or end is None:
87
+ # No character offsets (slow tokenizer / unit-test stub) — can't
88
+ # safely merge, so emit the raw word as a standalone term.
89
+ _flush()
90
+ _emit(span.get("word") or "", conf)
91
+ continue
92
+ # jjzha's labels carry no entity suffix, so aggregation_strategy=
93
+ # "simple" can't group a skill's B token with its following I tokens
94
+ # — multi-word skills ("machine learning") fragment. Re-stitch via
95
+ # offsets: a "B" begins a new skill; an "I" directly adjacent (<=1
96
+ # char gap, i.e. a single separator) extends the current one.
97
+ is_continuation = (
98
+ label != "B" and cur_start is not None and 0 <= start - cur_end <= 1
99
+ )
100
+ if is_continuation:
101
+ cur_end = max(cur_end, end)
102
+ cur_conf = min(cur_conf, conf)
103
+ else:
104
+ _flush()
105
+ cur_start, cur_end, cur_conf = start, end, conf
106
+ _flush()
107
+ return out
108
+
109
+ def available(self) -> bool:
110
+ try:
111
+ import transformers # noqa: F401
112
+ return True
113
+ except Exception:
114
+ return False
115
+
116
+
117
+ layer = JobBertLayer()
apps/accounts/ner/lexical.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer 0/floor: lexical catalog matching.
2
+
3
+ This wraps the existing pdfplumber-free matching logic from the MVP so the
4
+ dispatcher can treat it uniformly with the other layers. Unlike the four
5
+ ML layers, this one is always safe — it has no heavy deps and only touches
6
+ the DB's Skill catalog.
7
+
8
+ Kept separate from ``resume_parser`` so the dispatcher can invoke it as
9
+ a layer alongside the others rather than having a special-case code path.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from . import NerLayer
14
+
15
+
16
+ class LexicalLayer(NerLayer):
17
+ name = "lexical"
18
+
19
+ def predict(self, text: str) -> dict[str, float]:
20
+ if not text.strip():
21
+ return {}
22
+ # Reuse the existing implementation so behaviour stays identical.
23
+ from apps.accounts.resume_parser import _candidate_matches
24
+ from apps.skills.models import Skill
25
+
26
+ skills = list(Skill.objects.all().only("id", "skill_name"))
27
+ hits = _candidate_matches(text, skills)
28
+ # _candidate_matches returns {skill_id: {confidence, proficiency, matched_span}}
29
+ # — the dispatcher works with {name: confidence}, so remap.
30
+ by_id = {s.id: s.skill_name for s in skills}
31
+ out: dict[str, float] = {}
32
+ for skill_id, data in hits.items():
33
+ name = by_id.get(skill_id)
34
+ if name:
35
+ out[name] = float(data["confidence"])
36
+ return out
37
+
38
+ def available(self) -> bool:
39
+ return True
40
+
41
+
42
+ layer = LexicalLayer()
apps/accounts/ner/nucha.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer 1: Nucha/Nucha_ITSkillNER_BERT — BERT NER for IT skills.
2
+
3
+ Model: https://huggingface.co/Nucha/Nucha_ITSkillNER_BERT
4
+ - MIT license, 108.9M params, BERT architecture
5
+ - Labels: HSKILL (hard skill) + SSKILL (soft skill), F1 ~0.90
6
+ - Downloads ~440 MB into %USERPROFILE%\\.cache\\huggingface on first use.
7
+
8
+ Contract: returns {skill_span_text: confidence}. The dispatcher's alias
9
+ resolver decides which canonical Skill catalog row that maps to.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+
15
+ from . import NerLayer
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _MODEL_ID = "Nucha/Nucha_ITSkillNER_BERT"
21
+ _pipeline = None # set on first call, persists for the process lifetime
22
+
23
+
24
+ def _get_pipeline():
25
+ """Lazy singleton — the first call does the ~440 MB download; later
26
+ calls reuse the cached model. Raises on network failure; the dispatcher
27
+ catches and moves to the next layer.
28
+ """
29
+ global _pipeline
30
+ if _pipeline is None:
31
+ from transformers import pipeline # local import keeps lexical-only CI fast
32
+ logger.info("nucha: loading %s pipeline (first call may be slow)", _MODEL_ID)
33
+ _pipeline = pipeline(
34
+ task="ner",
35
+ model=_MODEL_ID,
36
+ aggregation_strategy="simple",
37
+ )
38
+ return _pipeline
39
+
40
+
41
+ class NuchaLayer(NerLayer):
42
+ name = "nucha"
43
+
44
+ def predict(self, text: str) -> dict[str, float]:
45
+ if not text.strip():
46
+ return {}
47
+ pipe = _get_pipeline()
48
+ # The pipeline returns a list of span dicts with keys
49
+ # {entity_group, score, word, start, end}. aggregation_strategy="simple"
50
+ # merges adjacent B-/I- tokens into full spans.
51
+ spans = pipe(text[:4000]) # cap at ~4K chars to bound latency
52
+ out: dict[str, float] = {}
53
+ for span in spans:
54
+ label = span.get("entity_group") or span.get("entity") or ""
55
+ if not label.upper().endswith("SKILL"):
56
+ # Tokens labelled 'O' or padding — drop.
57
+ continue
58
+ term = (span.get("word") or "").strip(" ,.;:-")
59
+ if len(term) < 2:
60
+ continue
61
+ conf = float(span.get("score") or 0.0)
62
+ # Merge duplicates — keep the max confidence seen.
63
+ if conf > out.get(term, 0.0):
64
+ out[term] = conf
65
+ return out
66
+
67
+ def available(self) -> bool:
68
+ try:
69
+ import transformers # noqa: F401
70
+ return True
71
+ except Exception:
72
+ return False
73
+
74
+
75
+ # Module-level singleton for the dispatcher to import cheaply.
76
+ layer = NuchaLayer()
apps/accounts/ner/sbert.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer 4: SBERT + pgvector cosine — degraded-fallback canonical mapping.
2
+
3
+ Uses sentence-transformers/all-MiniLM-L6-v2 (Apache 2.0, 22M params, 384 dims)
4
+ to embed candidate noun phrases from the resume text, then queries the
5
+ SkillEmbedding table via pgvector cosine distance to find the nearest
6
+ catalog skill. This is the always-available fallback for when higher BERT
7
+ layers can't load (memory-limited Render tier, offline, etc.) AND for
8
+ paraphrased skill language that lexical + BERT miss.
9
+
10
+ Threshold: 0.65 similarity (equivalent to cosine_distance <= 0.35). The
11
+ dispatcher's alias resolver uses the canonical ``skill_name`` keyed output
12
+ directly — no further fuzzy-match hop needed.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+
18
+ from . import NerLayer
19
+
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"
24
+ _SIM_THRESHOLD = 0.65
25
+
26
+ _model = None
27
+ _nlp = None
28
+
29
+
30
+ def _get_model():
31
+ global _model
32
+ if _model is None:
33
+ from sentence_transformers import SentenceTransformer
34
+ logger.info("sbert: loading %s (first call may be slow)", _MODEL_ID)
35
+ _model = SentenceTransformer(_MODEL_ID)
36
+ return _model
37
+
38
+
39
+ def _get_nlp():
40
+ """Reuse spaCy's en_core_web_lg for noun-phrase extraction.
41
+
42
+ If skillNer already loaded it, great — it's a module-level singleton in
43
+ that layer too. Otherwise we load our own.
44
+ """
45
+ global _nlp
46
+ if _nlp is None:
47
+ # Reuse skillner's loaded model if possible to save ~560 MB of RAM.
48
+ from . import skillner as _skillner_mod
49
+ cached = getattr(_skillner_mod, "_nlp", None)
50
+ if cached is not None:
51
+ _nlp = cached
52
+ else:
53
+ import spacy
54
+ # Prefer `_lg` for slightly better noun-chunk quality; fall back
55
+ # to `_sm` which is the minimum viable install documented in
56
+ # INSTALL.md.
57
+ try:
58
+ _nlp = spacy.load("en_core_web_lg")
59
+ except OSError:
60
+ _nlp = spacy.load("en_core_web_sm")
61
+ return _nlp
62
+
63
+
64
+ def _candidate_phrases(text: str) -> list[str]:
65
+ nlp = _get_nlp()
66
+ doc = nlp(text[:4000])
67
+ seen: set[str] = set()
68
+ out: list[str] = []
69
+ for chunk in doc.noun_chunks:
70
+ phrase = chunk.text.strip(" ,.;:-").lower()
71
+ # Skip very short fragments + stopwords-only spans — they lead to
72
+ # spurious cosine matches against broad skill names.
73
+ if len(phrase) < 3 or phrase in seen:
74
+ continue
75
+ seen.add(phrase)
76
+ out.append(phrase)
77
+ return out
78
+
79
+
80
+ class SBertLayer(NerLayer):
81
+ name = "sbert"
82
+
83
+ def predict(self, text: str) -> dict[str, float]:
84
+ if not text.strip():
85
+ return {}
86
+ # DB lookup: pgvector cosine against SkillEmbedding. Import Django
87
+ # models lazily so module import doesn't touch the DB.
88
+ from pgvector.django import CosineDistance
89
+
90
+ from apps.skills.models import Skill, SkillEmbedding
91
+
92
+ phrases = _candidate_phrases(text)
93
+ if not phrases:
94
+ return {}
95
+
96
+ model = _get_model()
97
+ embeddings = model.encode(phrases, normalize_embeddings=True).tolist()
98
+
99
+ out: dict[str, float] = {}
100
+ # One query per phrase — each hits the HNSW index. 20 phrases × ~2 ms
101
+ # per query = ~40 ms total, acceptable for on-demand parse.
102
+ for phrase, vec in zip(phrases, embeddings):
103
+ top = (
104
+ SkillEmbedding.objects
105
+ .annotate(distance=CosineDistance("embedding", vec))
106
+ .order_by("distance")
107
+ .select_related("skill")
108
+ .first()
109
+ )
110
+ if top is None:
111
+ continue
112
+ # Cosine distance = 1 - cosine similarity for normalized vectors.
113
+ similarity = 1.0 - float(top.distance)
114
+ if similarity < _SIM_THRESHOLD:
115
+ continue
116
+ name = top.skill.skill_name
117
+ if similarity > out.get(name, 0.0):
118
+ out[name] = similarity
119
+ return out
120
+
121
+ def available(self) -> bool:
122
+ try:
123
+ import sentence_transformers # noqa: F401
124
+ import pgvector # noqa: F401
125
+ from apps.skills.models import SkillEmbedding # noqa: F401
126
+ return True
127
+ except Exception:
128
+ return False
129
+
130
+
131
+ layer = SBertLayer()
apps/accounts/ner/skillner.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer 3: skillNer — rule-based EMSI-taxonomy matcher.
2
+
3
+ https://github.com/AnasAito/SkillNER — Nov 2021 last release, pure Python,
4
+ works on any spaCy `nlp` object (no version pin). Uses spaCy's PhraseMatcher
5
+ internally so CPU-only and fast.
6
+
7
+ Gracefully unavailable if its transitive deps (nltk, jellyfish) fail to
8
+ import — the dispatcher's try/except continues with the next layer.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ from . import NerLayer
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _extractor = None
20
+ _nlp = None
21
+ _import_error = None
22
+
23
+
24
+ def _get_extractor():
25
+ """Lazy singleton. Import both spaCy + skillNer on first call."""
26
+ global _extractor, _nlp, _import_error
27
+ if _extractor is not None:
28
+ return _extractor
29
+ if _import_error is not None:
30
+ # Already attempted and failed — surface the original error so the
31
+ # dispatcher can log once instead of re-throwing noisily.
32
+ raise _import_error
33
+
34
+ try:
35
+ import spacy
36
+ from skillNer.skill_extractor_class import SkillExtractor
37
+ from skillNer.general_params import SKILL_DB
38
+ from spacy.matcher import PhraseMatcher
39
+ except Exception as exc: # pragma: no cover — env-specific
40
+ _import_error = exc
41
+ logger.warning("skillner: unavailable (import failed): %s", exc)
42
+ raise
43
+
44
+ # Prefer `_lg` (word vectors help edge cases) but fall back to `_sm` if
45
+ # only the smaller model is installed. skillner uses spaCy's PhraseMatcher
46
+ # which is string-based, so both work; `_lg` is strictly better-quality
47
+ # when RAM / disk allow.
48
+ try:
49
+ _nlp = spacy.load("en_core_web_lg")
50
+ except OSError:
51
+ _nlp = spacy.load("en_core_web_sm")
52
+ # Pass the PhraseMatcher *class*, not an instance: skillNer constructs its
53
+ # own matcher internally via `self.phraseMatcher(nlp.vocab, attr="LOWER")`,
54
+ # so handing it an instance raises at construction and disables the layer.
55
+ _extractor = SkillExtractor(_nlp, SKILL_DB, PhraseMatcher)
56
+ logger.info("skillner: loaded (spaCy=%s, SKILL_DB=%d entries)",
57
+ spacy.__version__, len(SKILL_DB))
58
+ return _extractor
59
+
60
+
61
+ class SkillNerLayer(NerLayer):
62
+ name = "skillner"
63
+
64
+ def predict(self, text: str) -> dict[str, float]:
65
+ if not text.strip():
66
+ return {}
67
+ extractor = _get_extractor()
68
+ annotations = extractor.annotate(text[:4000])
69
+ out: dict[str, float] = {}
70
+ results = (annotations or {}).get("results") or {}
71
+ # full_matches: perfect catalog hits — high confidence.
72
+ for match in results.get("full_matches") or []:
73
+ term = (match.get("doc_node_value") or "").strip()
74
+ if term:
75
+ out[term] = max(out.get(term, 0.0), 0.95)
76
+ # ngram_scored: fuzzy catalog hits — skillNer includes its own score.
77
+ for match in results.get("ngram_scored") or []:
78
+ term = (match.get("doc_node_value") or "").strip()
79
+ score = float(match.get("score") or 0.0)
80
+ if term:
81
+ out[term] = max(out.get(term, 0.0), score)
82
+ return out
83
+
84
+ def available(self) -> bool:
85
+ if _import_error is not None:
86
+ return False
87
+ try:
88
+ import skillNer # noqa: F401
89
+ import spacy # noqa: F401
90
+ return True
91
+ except Exception:
92
+ return False
93
+
94
+
95
+ layer = SkillNerLayer()
apps/accounts/resume_parser.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resume parsing service — Module 8.
2
+
3
+ Four-layer NER fallback chain merged on top of a lexical floor:
4
+
5
+ Nucha BERT -> jobbert -> SkillNER -> SBERT+pgvector -> lexical (always)
6
+
7
+ All layers that succeed contribute to the merged output — "merge semantics"
8
+ rather than "first-success". This makes the fallback chain visible in the
9
+ demo and maximises recall for downstream gap analysis: different layers
10
+ catch different phrasings ("ML" vs "machine learning" vs "scikit-learn").
11
+
12
+ Each layer runs synchronously inside try/except; a layer that raises is logged
13
+ at ERROR and skipped while the rest of the chain continues. The lexical floor
14
+ always fires so the endpoint never returns empty solely because HF models are
15
+ unavailable.
16
+
17
+ Design constraints:
18
+
19
+ * The endpoint contract of ``extract_skills_from_pdf`` is stable: a list
20
+ of ``SkillPrediction`` dicts. Higher layers enrich confidence for skills
21
+ the lexical layer already found, and add new skills via alias resolution.
22
+ * Matching is done against the existing Skill catalog only. We never create
23
+ new skills from free text — prevents catalog pollution and keeps the
24
+ recommendation engine grounded in the curated taxonomy.
25
+ * Proficiency is a heuristic, never an assertion. The user reviews and
26
+ edits on the Profile page before anything is saved.
27
+
28
+ Layer selection via env var ``GAPGUIDE_PARSE_LAYERS`` (default is the full
29
+ chain; CI sets this to ``"lexical"`` via conftest.py so tests don't download
30
+ any models). See ~/.claude/plans/okay-plan-for-7a-fluffy-marshmallow.md.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import io
35
+ import logging
36
+ import os
37
+ import re
38
+ from dataclasses import asdict, dataclass
39
+
40
+ import pdfplumber
41
+ from rapidfuzz import fuzz, process
42
+
43
+ from apps.skills.models import Skill
44
+
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # Layer order — the dispatcher runs in this sequence. Merging is order-agnostic
49
+ # but the order here drives the "first to register a given skill wins the
50
+ # proficiency heuristic" rule. Lexical last (it already has the best local
51
+ # context), but we guarantee it runs last anyway in _predict_skills_from_text.
52
+ _DEFAULT_LAYERS = "nucha,jobbert,skillner,sbert,lexical"
53
+
54
+
55
+ MAX_PDF_BYTES = 5 * 1024 * 1024 # 5 MB — UCP students' CVs fit easily.
56
+ MAX_TEXT_CHARS = 60_000 # defends the downstream matcher.
57
+
58
+ # Rough heuristic — the parser is an *augmentation* layer, so we cap signals
59
+ # below 70 so the user always has room to raise after review.
60
+ _DEFAULT_PROFICIENCY = 55
61
+ _STRONG_SIGNAL_PROFICIENCY = 70
62
+
63
+ # Fuzzy layer is per-token (not partial_ratio against the full doc), so the
64
+ # threshold is genuinely "this word is essentially a typo of the skill name".
65
+ # Anything lower would flood the output with false positives like
66
+ # partial_ratio("python", "sourdough") > 0.5.
67
+ _FUZZY_TOKEN_SCORE = 88
68
+ _FUZZY_MIN_SKILL_LEN = 5 # 'Go', 'R', 'SQL' never go through fuzzy path
69
+
70
+ # Terms that indicate hands-on experience → pushes proficiency toward the top.
71
+ _EXPERIENCE_MARKERS = re.compile(
72
+ r"\b(?:expert|advanced|proficien[tc]y?|senior|lead|architect|years?|yrs?)\b",
73
+ re.IGNORECASE,
74
+ )
75
+
76
+ # Terms that hint at entry level → pulls proficiency down.
77
+ _NOVICE_MARKERS = re.compile(
78
+ r"\b(?:beginner|junior|intro(?:duction)?|basic|elementary|familiar with)\b",
79
+ re.IGNORECASE,
80
+ )
81
+
82
+ # Rank by *signal strength*, NOT numeric proficiency. NOVICE=40 is numerically
83
+ # less than DEFAULT=55 but carries a stronger claim ("beginner Python" beats
84
+ # silence), so it should win during window aggregation.
85
+ _NOVICE_PROFICIENCY = 40
86
+ _PROFICIENCY_RANK = {
87
+ _STRONG_SIGNAL_PROFICIENCY: 2,
88
+ _NOVICE_PROFICIENCY: 1,
89
+ _DEFAULT_PROFICIENCY: 0,
90
+ }
91
+
92
+ # Per-layer calibration coefficient applied before the max() merge in
93
+ # _predict_skills_from_text. Each layer emits [0,1] from a different
94
+ # distribution — softmax (Nucha/JobBERT), cosine similarity above 0.65 threshold
95
+ # (SBERT), hardcoded 0.95 / rapidfuzz ratio (lexical). Raw max lets lexical's
96
+ # hardcoded 0.95 outweigh a genuine model hit of 0.92. Values are heuristic —
97
+ # a Platt-scaling rebuild from labelled data is out of FYP scope, but these at
98
+ # least stop the hardcoded layers from dominating live model output.
99
+ _LAYER_CALIBRATION = {
100
+ 'nucha': 1.0,
101
+ 'jobbert': 1.0,
102
+ 'skillner': 0.90,
103
+ 'sbert': 0.85,
104
+ 'lexical': 0.95,
105
+ }
106
+
107
+
108
+ class ResumeParseError(ValueError):
109
+ """Raised for malformed or oversized inputs."""
110
+
111
+
112
+ @dataclass
113
+ class SkillPrediction:
114
+ skill_id: int
115
+ skill_name: str
116
+ proficiency: int
117
+ confidence: float
118
+ matched_span: str
119
+
120
+ def to_dict(self) -> dict:
121
+ return asdict(self)
122
+
123
+
124
+ def _extract_text(pdf_bytes: bytes) -> str:
125
+ if len(pdf_bytes) > MAX_PDF_BYTES:
126
+ raise ResumeParseError(
127
+ f"Resume exceeds the {MAX_PDF_BYTES // 1024 // 1024} MB upload limit."
128
+ )
129
+ if not pdf_bytes:
130
+ raise ResumeParseError("Empty upload.")
131
+
132
+ try:
133
+ with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
134
+ pages = []
135
+ for page in pdf.pages:
136
+ text = page.extract_text() or ""
137
+ pages.append(text)
138
+ if sum(len(p) for p in pages) > MAX_TEXT_CHARS:
139
+ break
140
+ full_text = "\n".join(pages)
141
+ except Exception as exc:
142
+ # pdfplumber raises a grab-bag of exceptions for corrupt PDFs; treat
143
+ # them all as user input errors rather than 500s.
144
+ raise ResumeParseError(f"Could not read PDF: {exc}") from exc
145
+
146
+ if not full_text.strip():
147
+ raise ResumeParseError(
148
+ "No text could be extracted. If your CV is a scanned image, "
149
+ "re-export it as a text-based PDF."
150
+ )
151
+ return full_text[:MAX_TEXT_CHARS]
152
+
153
+
154
+ def _classify_proficiency(context: str) -> tuple[int, float]:
155
+ """Return (proficiency, confidence_boost) from surrounding context.
156
+
157
+ A short window around the matched span is all we get — this is purposely
158
+ coarse. Higher layers can refine with a real classifier later.
159
+ """
160
+ if _EXPERIENCE_MARKERS.search(context):
161
+ return _STRONG_SIGNAL_PROFICIENCY, 0.10
162
+ if _NOVICE_MARKERS.search(context):
163
+ return _NOVICE_PROFICIENCY, 0.05
164
+ return _DEFAULT_PROFICIENCY, 0.0
165
+
166
+
167
+ def _classify_aggregated(contexts: list[str]) -> tuple[int, float]:
168
+ """Pick the strongest signal across all window contexts for a single skill.
169
+
170
+ Short-circuits on STRONG (the ceiling). Ranks by _PROFICIENCY_RANK, not by
171
+ raw proficiency value, so NOVICE ("beginner X") beats DEFAULT (silence)
172
+ even though NOVICE=40 < DEFAULT=55.
173
+ """
174
+ best = (_DEFAULT_PROFICIENCY, 0.0)
175
+ best_rank = 0
176
+ for ctx in contexts:
177
+ prof, boost = _classify_proficiency(ctx)
178
+ rank = _PROFICIENCY_RANK.get(prof, 0)
179
+ if rank > best_rank:
180
+ best, best_rank = (prof, boost), rank
181
+ if rank == 2: # STRONG — can't improve further.
182
+ break
183
+ return best
184
+
185
+
186
+ def _windows_for(text: str, pattern: str, context_chars: int = 60) -> list[str]:
187
+ """Yield ±context_chars windows around every match of `pattern` in text."""
188
+ windows = []
189
+ for match in re.finditer(pattern, text.lower()):
190
+ start = max(0, match.start() - context_chars)
191
+ end = min(len(text), match.end() + context_chars)
192
+ windows.append(text[start:end])
193
+ return windows
194
+
195
+
196
+ _TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9+#.\-]{2,}")
197
+
198
+
199
+ def _candidate_matches(text: str, skills: list[Skill]) -> dict[int, dict]:
200
+ """Return {skill_id: {'confidence', 'proficiency', 'matched_span'}} for
201
+ every catalog skill that appears in the text.
202
+
203
+ Two passes:
204
+ 1. Exact (case-insensitive) boundary-aware substring hit → high
205
+ confidence (0.95). We do NOT include '.' in the boundary character
206
+ class because tech names like "Node.js" already round-trip through
207
+ ``re.escape`` and sentence-final punctuation like "... SQL." must
208
+ still match.
209
+ 2. Token-level fuzzy match — for each token in the document that's
210
+ long enough, rapidfuzz.fuzz.ratio against the catalog. ``partial_
211
+ ratio`` was rejected because it false-positives aggressively
212
+ ("python" vs "sourdough" scored ~0.5). Per-token ``ratio`` with a
213
+ high threshold (88) only fires on near-typos.
214
+ """
215
+ hits: dict[int, dict] = {}
216
+
217
+ for skill in skills:
218
+ name = skill.skill_name.strip()
219
+ if not name:
220
+ continue
221
+ needle = name.lower()
222
+ # Word-boundary exact match — '\b' handles punctuation around
223
+ # identifier-like tech names. '.' is intentionally excluded from the
224
+ # boundary class so "SQL." at end-of-sentence still matches.
225
+ pattern = r"(?<![A-Za-z0-9+#\-])" + re.escape(needle) + r"(?![A-Za-z0-9+#\-])"
226
+ windows = _windows_for(text, pattern)
227
+ if windows:
228
+ prof, boost = _classify_aggregated(windows)
229
+ # First window is kept as the human-readable matched_span — the
230
+ # strongest-signal window may be far away, and the first occurrence
231
+ # is what the reviewer will visually scan for.
232
+ hits[skill.id] = {
233
+ 'confidence': round(min(0.95 + boost, 0.99), 3),
234
+ 'proficiency': prof,
235
+ 'matched_span': windows[0].strip(),
236
+ }
237
+
238
+ # Fuzzy pass: build the document's token set once, then for each unmatched
239
+ # skill find its best-matching token (rapidfuzz.process.extractOne).
240
+ tokens = list({m.group(0) for m in _TOKEN_RE.finditer(text)})
241
+ if not tokens:
242
+ return hits
243
+
244
+ for skill in skills:
245
+ if skill.id in hits:
246
+ continue
247
+ name = skill.skill_name.strip()
248
+ if len(name) < _FUZZY_MIN_SKILL_LEN:
249
+ # Short names ('Go', 'R', 'SQL') only travel the exact path.
250
+ continue
251
+ candidate = process.extractOne(
252
+ name.lower(),
253
+ [t.lower() for t in tokens],
254
+ scorer=fuzz.ratio,
255
+ score_cutoff=_FUZZY_TOKEN_SCORE,
256
+ )
257
+ if candidate is None:
258
+ continue
259
+ _, score, idx = candidate
260
+ hits[skill.id] = {
261
+ 'confidence': round(score / 100.0, 3),
262
+ 'proficiency': _DEFAULT_PROFICIENCY,
263
+ 'matched_span': tokens[idx],
264
+ }
265
+
266
+ return hits
267
+
268
+
269
+ def _load_layer(layer_name: str):
270
+ """Lazy-import a layer module by name. Returns None if import fails.
271
+
272
+ Done this way (rather than an eager registry) so a CI run pinned to
273
+ ``GAPGUIDE_PARSE_LAYERS=lexical`` never imports transformers/torch.
274
+ """
275
+ try:
276
+ # `apps.accounts.ner.<name>` — lives next to this module.
277
+ mod = __import__(
278
+ f"apps.accounts.ner.{layer_name}",
279
+ fromlist=["layer"],
280
+ )
281
+ return getattr(mod, "layer", None)
282
+ except Exception as exc:
283
+ logger.warning("ner.%s: import failed, skipping (%s)", layer_name, exc)
284
+ return None
285
+
286
+
287
+ def _enabled_layers() -> list[str]:
288
+ raw = os.environ.get("GAPGUIDE_PARSE_LAYERS", _DEFAULT_LAYERS)
289
+ out = [s.strip() for s in raw.split(",") if s.strip()]
290
+ # Always guarantee lexical at the end — contract says the floor fires.
291
+ if "lexical" not in out:
292
+ out.append("lexical")
293
+ return out
294
+
295
+
296
+ def _find_span_for(text: str, skill_name: str) -> tuple[int, str]:
297
+ """Search the text for `skill_name` and derive (proficiency, matched_span).
298
+
299
+ Used to enrich non-lexical layer hits with the same surrounding-context
300
+ proficiency heuristic the lexical layer uses. If the skill name isn't
301
+ literally in the text (the layer inferred it from a paraphrase), fall
302
+ back to the default proficiency + empty span.
303
+
304
+ Multi-window aggregation: if the skill appears multiple times, the
305
+ strongest *signal* across windows wins (STRONG > NOVICE > DEFAULT).
306
+ """
307
+ needle = re.escape(skill_name.lower())
308
+ pattern = r"(?<![A-Za-z0-9+#\-])" + needle + r"(?![A-Za-z0-9+#\-])"
309
+ windows = _windows_for(text, pattern)
310
+ if not windows:
311
+ return _DEFAULT_PROFICIENCY, ''
312
+ prof, _ = _classify_aggregated(windows)
313
+ return prof, windows[0].strip()
314
+
315
+
316
+ def _resolve_alias(alias: str, skills_by_lower: dict, all_skills: list[Skill]) -> Skill | None:
317
+ """Map a free-text alias to a canonical Skill row.
318
+
319
+ Exact match (case-insensitive) -> fuzzy match via rapidfuzz using the
320
+ same threshold as the lexical layer. Returns None if nothing confident
321
+ enough is found. Aliases too short to fuzzy-match just require an exact
322
+ hit; keeps 'Go' and 'R' from matching arbitrary noun phrases.
323
+ """
324
+ if not alias:
325
+ return None
326
+ key = alias.strip().lower()
327
+ hit = skills_by_lower.get(key)
328
+ if hit is not None:
329
+ return hit
330
+ if len(key) < _FUZZY_MIN_SKILL_LEN:
331
+ return None
332
+ candidate = process.extractOne(
333
+ key,
334
+ list(skills_by_lower.keys()),
335
+ scorer=fuzz.ratio,
336
+ score_cutoff=_FUZZY_TOKEN_SCORE,
337
+ )
338
+ if candidate is None:
339
+ return None
340
+ matched_name, _, _ = candidate
341
+ return skills_by_lower.get(matched_name)
342
+
343
+
344
+ def _predict_skills_from_text(text: str) -> tuple[dict[int, dict], list[str]]:
345
+ """Run enabled NER layers against `text` and merge their hits.
346
+
347
+ Returns (hits_by_skill_id, fired_layers):
348
+ - hits_by_skill_id: {skill_id: {confidence, proficiency, matched_span}}
349
+ - fired_layers: layers that actually returned results (in order)
350
+
351
+ Each layer runs synchronously. A layer that raises during predict() is
352
+ logged at ERROR (with traceback) and skipped — the rest of the chain
353
+ continues. (Import failures are caught earlier in _load_layer and logged
354
+ at WARNING.) If ALL layers fail, the endpoint still returns 200 with the
355
+ lexical-floor output (since lexical is always run).
356
+ """
357
+ skills = list(Skill.objects.all().only('id', 'skill_name'))
358
+ skills_by_lower = {s.skill_name.lower(): s for s in skills}
359
+
360
+ hits: dict[int, dict] = {}
361
+ fired: list[str] = []
362
+
363
+ for layer_name in _enabled_layers():
364
+ layer = _load_layer(layer_name)
365
+ if layer is None or not layer.available():
366
+ continue
367
+ try:
368
+ # Run synchronously. A previous version ran each layer in a
369
+ # ThreadPoolExecutor for timeout support, but Django's ORM uses
370
+ # thread-local connections — the new thread never saw the test
371
+ # transaction's uncommitted rows, so the lexical/sbert layers
372
+ # found nothing under pytest-django. HF client libraries already
373
+ # apply their own request timeouts, so dropping the local one
374
+ # costs us only a theoretical hang on a misbehaving custom layer.
375
+ layer_out = layer.predict(text)
376
+ except Exception as exc:
377
+ # Keep the catch broad (a flaky HF layer must never 500 the upload)
378
+ # but log at ERROR with the traceback — the always-on net that makes
379
+ # a silently-skipped layer (e.g. a wiring/construction bug) visible
380
+ # in the logs instead of vanishing.
381
+ logger.error(
382
+ "ner.%s: raised %s, skipping", layer_name, exc, exc_info=True,
383
+ )
384
+ continue
385
+
386
+ if not layer_out:
387
+ continue
388
+ fired.append(layer_name)
389
+
390
+ calibration = _LAYER_CALIBRATION.get(layer_name, 1.0)
391
+ for alias, conf in layer_out.items():
392
+ skill = _resolve_alias(alias, skills_by_lower, skills)
393
+ if skill is None:
394
+ continue
395
+ existing = hits.get(skill.id)
396
+ # Calibrate per-layer so a 0.92 softmax isn't dominated by a
397
+ # hardcoded lexical 0.95. Cap below 1.0 to keep downstream plots
398
+ # away from the edge of the [0,1] axis.
399
+ conf_f = round(min(float(conf) * calibration, 0.99), 3)
400
+ if existing is None:
401
+ prof, span = _find_span_for(text, skill.skill_name)
402
+ hits[skill.id] = {
403
+ 'confidence': conf_f,
404
+ 'proficiency': prof,
405
+ 'matched_span': span,
406
+ }
407
+ elif conf_f > existing['confidence']:
408
+ existing['confidence'] = conf_f
409
+
410
+ return hits, fired
411
+
412
+
413
+ def extract_skills_from_pdf(pdf_bytes: bytes) -> list[dict]:
414
+ """Parse a resume PDF and return ordered, catalog-resolved skill predictions.
415
+
416
+ Kept as a list-only return to preserve the MVP test contract. The view
417
+ uses :func:`parse_resume_envelope` instead, which also carries the list
418
+ of layers that fired (the dispatcher's `parser_version`).
419
+ """
420
+ env = parse_resume_envelope(pdf_bytes)
421
+ return env['skills']
422
+
423
+
424
+ def parse_resume_envelope(pdf_bytes: bytes) -> dict:
425
+ """Full response envelope: ``{'skills': [...], 'parser_version': [...]}``.
426
+
427
+ `parser_version` is a list of layer names so the frontend can render a
428
+ real "which layers fired" chip sequence (`["nucha", "lexical"]`) rather
429
+ than the MVP's opaque `"mvp-lexical-v1"` string.
430
+ """
431
+ text = _extract_text(pdf_bytes)
432
+ scored, fired = _predict_skills_from_text(text)
433
+ if not scored:
434
+ return {'skills': [], 'parser_version': fired}
435
+
436
+ skill_lookup = {
437
+ s.id: s for s in Skill.objects.filter(id__in=scored.keys())
438
+ }
439
+ predictions: list[SkillPrediction] = []
440
+ for skill_id, data in scored.items():
441
+ skill = skill_lookup.get(skill_id)
442
+ if skill is None:
443
+ continue
444
+ predictions.append(SkillPrediction(
445
+ skill_id=skill_id,
446
+ skill_name=skill.skill_name,
447
+ proficiency=int(data['proficiency']),
448
+ confidence=float(data['confidence']),
449
+ matched_span=str(data['matched_span'])[:200],
450
+ ))
451
+
452
+ predictions.sort(key=lambda p: (-p.confidence, p.skill_name))
453
+ return {
454
+ 'skills': [p.to_dict() for p in predictions],
455
+ 'parser_version': fired,
456
+ }
apps/accounts/serializers.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.contrib.auth import authenticate
2
+ from django.contrib.auth.password_validation import validate_password as django_validate_password
3
+ from django.core.exceptions import ValidationError as DjangoValidationError
4
+ from drf_spectacular.utils import extend_schema_field
5
+ from rest_framework import serializers
6
+ from .models import User, UserProfile
7
+ from apps.skills.models import UserSkill
8
+ from apps.skills.serializers import UserSkillSerializer
9
+
10
+
11
+ class RegisterSerializer(serializers.ModelSerializer):
12
+ password_confirm = serializers.CharField(write_only=True)
13
+
14
+ class Meta:
15
+ model = User
16
+ fields = ['name', 'email', 'password', 'password_confirm']
17
+ extra_kwargs = {
18
+ 'password': {'write_only': True},
19
+ }
20
+
21
+ def validate(self, attrs):
22
+ if attrs['password'] != attrs['password_confirm']:
23
+ raise serializers.ValidationError({"password": "Passwords do not match."})
24
+ return attrs
25
+
26
+ def validate_email(self, value):
27
+ # Case-insensitive uniqueness: store lowercase so `Foo@X.com` and
28
+ # `foo@x.com` cannot both register, and login is case-insensitive too.
29
+ normalized = value.strip().lower()
30
+ if User.objects.filter(email__iexact=normalized).exists():
31
+ raise serializers.ValidationError("A user with this email already exists.")
32
+ return normalized
33
+
34
+ def validate_password(self, value):
35
+ # Run Django's configured AUTH_PASSWORD_VALIDATORS (min-length, common,
36
+ # numeric, user-similarity). Without this call the settings list is dead
37
+ # config.
38
+ try:
39
+ django_validate_password(value)
40
+ except DjangoValidationError as exc:
41
+ raise serializers.ValidationError(list(exc.messages))
42
+ return value
43
+
44
+ def create(self, validated_data):
45
+ validated_data.pop('password_confirm')
46
+ email = validated_data['email']
47
+ user = User.objects.create_user(
48
+ username=email,
49
+ email=email,
50
+ name=validated_data.get('name', ''),
51
+ password=validated_data['password'],
52
+ role_type='STUDENT'
53
+ )
54
+ return user
55
+
56
+
57
+ class LoginSerializer(serializers.Serializer):
58
+ email = serializers.EmailField(write_only=True)
59
+ password = serializers.CharField(write_only=True)
60
+
61
+ def validate(self, attrs):
62
+ # Normalize to match the lowercase username stored at registration.
63
+ email = (attrs.get('email') or '').strip().lower()
64
+ user = authenticate(
65
+ request=self.context.get('request'),
66
+ username=email,
67
+ password=attrs.get('password')
68
+ )
69
+
70
+ if not user:
71
+ raise serializers.ValidationError("Invalid credentials.")
72
+
73
+ if not user.is_active:
74
+ raise serializers.ValidationError("User account is disabled.")
75
+
76
+ return user
77
+
78
+
79
+ class UserSerializer(serializers.ModelSerializer):
80
+ class Meta:
81
+ model = User
82
+ fields = ['id', 'name', 'email', 'role_type', 'created_at']
83
+ read_only_fields = ['id', 'name', 'email', 'role_type', 'created_at']
84
+
85
+
86
+ class UserProfileSerializer(serializers.ModelSerializer):
87
+ class Meta:
88
+ model = UserProfile
89
+ fields = ['degree', 'semester', 'interests', 'bio', 'updated_at']
90
+ read_only_fields = ['updated_at']
91
+
92
+
93
+ class ProfileWithSkillsSerializer(serializers.Serializer):
94
+ user = serializers.SerializerMethodField()
95
+ profile = serializers.SerializerMethodField()
96
+ skills = serializers.SerializerMethodField()
97
+
98
+ @extend_schema_field(UserSerializer)
99
+ def get_user(self, obj):
100
+ return UserSerializer(obj).data
101
+
102
+ @extend_schema_field(UserProfileSerializer)
103
+ def get_profile(self, obj):
104
+ try:
105
+ return UserProfileSerializer(obj.profile).data
106
+ except UserProfile.DoesNotExist:
107
+ return {}
108
+
109
+ @extend_schema_field(UserSkillSerializer(many=True))
110
+ def get_skills(self, obj):
111
+ user_skills = UserSkill.objects.filter(user=obj).select_related('skill')
112
+ return UserSkillSerializer(user_skills, many=True).data
apps/accounts/tests/__init__.py ADDED
File without changes
apps/accounts/tests/test_auth.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from rest_framework.test import APIClient
3
+
4
+ pytestmark = pytest.mark.django_db
5
+
6
+ VALID_USER = {
7
+ 'name': 'Test User',
8
+ 'email': 'test@example.com',
9
+ 'password': 'TestPass123!',
10
+ 'password_confirm': 'TestPass123!'
11
+ }
12
+
13
+ REGISTER_URL = '/api/auth/register/'
14
+ LOGIN_URL = '/api/auth/login/'
15
+ LOGOUT_URL = '/api/auth/logout/'
16
+ ME_URL = '/api/auth/me/'
17
+
18
+
19
+ @pytest.fixture
20
+ def api_client():
21
+ return APIClient()
22
+
23
+
24
+ @pytest.fixture
25
+ def user_data():
26
+ return VALID_USER.copy()
27
+
28
+
29
+ @pytest.fixture
30
+ def registered_user(api_client, user_data):
31
+ """Register a user and return the response data."""
32
+ response = api_client.post(REGISTER_URL, data=user_data, format='json')
33
+ return response.data
34
+
35
+
36
+ class TestRegistration:
37
+ def test_valid_registration(self, api_client, user_data):
38
+ """TC-01: Valid registration returns 201 with tokens."""
39
+ response = api_client.post(REGISTER_URL, data=user_data, format='json')
40
+ assert response.status_code == 201
41
+ assert 'access' in response.data
42
+ assert 'refresh' in response.data
43
+ assert 'user' in response.data
44
+ assert response.data['user']['email'] == user_data['email']
45
+
46
+ def test_duplicate_email(self, api_client, user_data):
47
+ """TC-02: Duplicate email returns 400."""
48
+ api_client.post(REGISTER_URL, data=user_data, format='json')
49
+ response = api_client.post(REGISTER_URL, data=user_data, format='json')
50
+ assert response.status_code == 400
51
+
52
+ def test_password_mismatch(self, api_client, user_data):
53
+ """Password mismatch returns 400."""
54
+ user_data['password_confirm'] = 'DifferentPassword!'
55
+ response = api_client.post(REGISTER_URL, data=user_data, format='json')
56
+ assert response.status_code == 400
57
+
58
+ def test_missing_fields(self, api_client):
59
+ """Missing required fields returns 400."""
60
+ response = api_client.post(REGISTER_URL, data={'email': 'x@x.com'}, format='json')
61
+ assert response.status_code == 400
62
+
63
+
64
+ class TestLogin:
65
+ def test_valid_login(self, api_client, user_data, registered_user):
66
+ """TC-03: Valid login returns 200 with tokens."""
67
+ login_data = {'email': user_data['email'], 'password': user_data['password']}
68
+ response = api_client.post(LOGIN_URL, data=login_data, format='json')
69
+ assert response.status_code == 200
70
+ assert 'access' in response.data
71
+ assert 'refresh' in response.data
72
+
73
+ def test_invalid_credentials(self, api_client, user_data, registered_user):
74
+ """TC-04: Wrong password returns 400."""
75
+ login_data = {'email': user_data['email'], 'password': 'WrongPassword!'}
76
+ response = api_client.post(LOGIN_URL, data=login_data, format='json')
77
+ assert response.status_code == 400
78
+
79
+
80
+ class TestMe:
81
+ def test_me_without_token(self, api_client):
82
+ """GET /me/ without token returns 401."""
83
+ response = api_client.get(ME_URL)
84
+ assert response.status_code == 401
85
+
86
+ def test_get_me(self, api_client, user_data, registered_user):
87
+ """Authenticated GET /me/ returns user data."""
88
+ access_token = registered_user['access']
89
+ api_client.credentials(HTTP_AUTHORIZATION='Bearer ' + access_token)
90
+ response = api_client.get(ME_URL)
91
+ assert response.status_code == 200
92
+ assert response.data['email'] == user_data['email']
93
+
94
+
95
+ class TestLoginThrottle:
96
+ def test_login_rate_limit_blocks_after_threshold(
97
+ self, api_client, user_data, registered_user,
98
+ ):
99
+ """Login endpoint is throttled at 10/minute — verify the 11th
100
+ attempt is rejected with 429, regardless of credentials."""
101
+ bad = {'email': user_data['email'], 'password': 'nope'}
102
+ # Burn through the limit with invalid credentials (400 per attempt).
103
+ for _ in range(10):
104
+ r = api_client.post(LOGIN_URL, data=bad, format='json')
105
+ assert r.status_code in (400, 401)
106
+ # 11th must be throttled.
107
+ r = api_client.post(LOGIN_URL, data=bad, format='json')
108
+ assert r.status_code == 429
109
+
110
+
111
+ class TestLogout:
112
+ def test_logout(self, api_client, user_data, registered_user):
113
+ """Logout blacklists refresh token, returns 204."""
114
+ access_token = registered_user['access']
115
+ refresh_token = registered_user['refresh']
116
+
117
+ api_client.credentials(HTTP_AUTHORIZATION='Bearer ' + access_token)
118
+ response = api_client.post(
119
+ LOGOUT_URL,
120
+ data={'refresh_token': refresh_token},
121
+ format='json'
122
+ )
123
+ assert response.status_code == 204
124
+
125
+
126
+ class TestRegistrationRace:
127
+ def test_concurrent_email_race_returns_400(self, api_client, user_data):
128
+ """F2: a concurrent case-variant registration can bypass the
129
+ case-insensitive validate_email check; the DB unique index then raises
130
+ IntegrityError. The view must surface a clean 400 keyed on `email`,
131
+ not an unhandled 500."""
132
+ from unittest.mock import patch
133
+ from django.db import IntegrityError
134
+ with patch(
135
+ 'apps.accounts.serializers.RegisterSerializer.save',
136
+ side_effect=IntegrityError,
137
+ ):
138
+ response = api_client.post(REGISTER_URL, data=user_data, format='json')
139
+ assert response.status_code == 400
140
+ assert 'email' in response.data
apps/accounts/tests/test_ner_dispatcher.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dispatcher tests for the four-layer NER fallback chain.
2
+
3
+ Covers:
4
+ * Merge semantics — outputs from multiple layers union into one alias dict.
5
+ * Per-layer failures are silently skipped; remaining layers still contribute.
6
+ * If every layer raises, the endpoint still returns 200 with the lexical
7
+ floor output (since lexical is always appended to the enabled list).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from unittest.mock import patch
12
+
13
+ import pytest
14
+
15
+ from apps.skills.models import Skill
16
+
17
+
18
+ pytestmark = pytest.mark.django_db
19
+
20
+
21
+ @pytest.fixture
22
+ def catalog():
23
+ py = Skill.objects.create(skill_name='Python', category='Programming',
24
+ difficulty_level='BEGINNER')
25
+ sql = Skill.objects.create(skill_name='SQL', category='Database',
26
+ difficulty_level='BEGINNER')
27
+ react = Skill.objects.create(skill_name='React', category='Frontend',
28
+ difficulty_level='BEGINNER')
29
+ return {'python': py, 'sql': sql, 'react': react}
30
+
31
+
32
+ def _make_layer(name, predictions):
33
+ """Build a lightweight object that quacks like NerLayer."""
34
+ class _Fake:
35
+ pass
36
+ fake = _Fake()
37
+ fake.name = name
38
+ fake.predict = lambda _text: predictions
39
+ fake.available = lambda: True
40
+ return fake
41
+
42
+
43
+ def test_merge_semantics_union_hits_from_multiple_layers(catalog):
44
+ from apps.accounts.resume_parser import _predict_skills_from_text
45
+
46
+ layers = {
47
+ 'nucha': _make_layer('nucha', {'Python': 0.92}),
48
+ 'jobbert': _make_layer('jobbert', {'SQL': 0.81}),
49
+ 'lexical': _make_layer('lexical', {'React': 0.95}),
50
+ }
51
+
52
+ with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \
53
+ patch('apps.accounts.resume_parser._enabled_layers',
54
+ return_value=['nucha', 'jobbert', 'lexical']):
55
+ hits, fired = _predict_skills_from_text("Python SQL React")
56
+
57
+ assert fired == ['nucha', 'jobbert', 'lexical']
58
+ skill_ids = {catalog['python'].id, catalog['sql'].id, catalog['react'].id}
59
+ assert set(hits) == skill_ids
60
+
61
+
62
+ def test_max_confidence_kept_when_layers_agree(catalog):
63
+ from apps.accounts.resume_parser import _predict_skills_from_text
64
+
65
+ layers = {
66
+ 'nucha': _make_layer('nucha', {'Python': 0.70}),
67
+ 'jobbert': _make_layer('jobbert', {'Python': 0.92}), # higher
68
+ 'lexical': _make_layer('lexical', {'Python': 0.85}),
69
+ }
70
+ with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \
71
+ patch('apps.accounts.resume_parser._enabled_layers',
72
+ return_value=['nucha', 'jobbert', 'lexical']):
73
+ hits, _ = _predict_skills_from_text("Python Python Python")
74
+
75
+ py_id = catalog['python'].id
76
+ # round(min(0.92, 0.99), 3) == 0.92
77
+ assert hits[py_id]['confidence'] == 0.92
78
+
79
+
80
+ def test_failing_layer_is_skipped_silently(catalog):
81
+ """One layer raises — dispatcher logs an error and continues with others."""
82
+ from apps.accounts.resume_parser import _predict_skills_from_text
83
+
84
+ class _Exploding:
85
+ name = 'nucha'
86
+ def predict(self, _): raise RuntimeError("HF hub is down")
87
+ def available(self): return True
88
+
89
+ layers = {
90
+ 'nucha': _Exploding(),
91
+ 'lexical': _make_layer('lexical', {'Python': 0.95}),
92
+ }
93
+ with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \
94
+ patch('apps.accounts.resume_parser._enabled_layers',
95
+ return_value=['nucha', 'lexical']):
96
+ hits, fired = _predict_skills_from_text("Python rocks")
97
+
98
+ assert fired == ['lexical'] # nucha dropped silently
99
+ assert catalog['python'].id in hits
100
+
101
+
102
+ def test_all_layers_fail_falls_back_to_empty_but_no_exception(catalog):
103
+ """If literally every layer raises, dispatcher returns (empty, empty) and
104
+ the endpoint still 200s — never propagates the layer failures."""
105
+ from apps.accounts.resume_parser import _predict_skills_from_text
106
+
107
+ class _Exploding:
108
+ def __init__(self, name): self.name = name
109
+ def predict(self, _): raise RuntimeError("boom")
110
+ def available(self): return True
111
+
112
+ layers = {
113
+ 'nucha': _Exploding('nucha'),
114
+ 'lexical': _Exploding('lexical'),
115
+ }
116
+ with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \
117
+ patch('apps.accounts.resume_parser._enabled_layers',
118
+ return_value=['nucha', 'lexical']):
119
+ hits, fired = _predict_skills_from_text("Python SQL")
120
+
121
+ assert hits == {}
122
+ assert fired == []
123
+
124
+
125
+ def test_alias_resolution_fuzzy_matches_typos(catalog):
126
+ """A non-lexical layer returning a near-miss alias still resolves to the
127
+ catalog row via rapidfuzz."""
128
+ from apps.accounts.resume_parser import _predict_skills_from_text
129
+
130
+ # "phython" → "Python" via rapidfuzz ratio ~92 (above 88 threshold).
131
+ # A two-letter transposition like "pyhton" only scores ~83 — on the
132
+ # wrong side of the cutoff — so pick a typo that lands decisively above it.
133
+ layers = {
134
+ 'nucha': _make_layer('nucha', {'phython': 0.90}),
135
+ 'lexical': _make_layer('lexical', {}),
136
+ }
137
+ with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \
138
+ patch('apps.accounts.resume_parser._enabled_layers',
139
+ return_value=['nucha', 'lexical']):
140
+ hits, _ = _predict_skills_from_text("phython is my favourite")
141
+
142
+ assert catalog['python'].id in hits
143
+
144
+
145
+ def test_alias_resolution_drops_unknown_skills(catalog):
146
+ """A layer claiming 'COBOL' when the catalog has no COBOL row → ignored."""
147
+ from apps.accounts.resume_parser import _predict_skills_from_text
148
+
149
+ layers = {
150
+ 'nucha': _make_layer('nucha', {'COBOL': 0.90, 'Brainfuck': 0.85}),
151
+ 'lexical': _make_layer('lexical', {}),
152
+ }
153
+ with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \
154
+ patch('apps.accounts.resume_parser._enabled_layers',
155
+ return_value=['nucha', 'lexical']):
156
+ hits, _ = _predict_skills_from_text("COBOL Brainfuck")
157
+
158
+ assert hits == {}
159
+
160
+
161
+ def test_layer_calibration_flips_max_when_lexical_outweighs_model(catalog):
162
+ """Per-layer calibration coefficients prevent lexical's hardcoded 0.95 from
163
+ dominating a genuine model hit of 0.92. With _LAYER_CALIBRATION:
164
+ lexical 0.95 × 0.95 = 0.9025
165
+ nucha 0.92 × 1.00 = 0.92
166
+ Nucha now wins. Without calibration lexical would win at 0.95.
167
+ """
168
+ from apps.accounts.resume_parser import _predict_skills_from_text
169
+
170
+ layers = {
171
+ 'nucha': _make_layer('nucha', {'Python': 0.92}),
172
+ 'lexical': _make_layer('lexical', {'Python': 0.95}),
173
+ }
174
+ with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \
175
+ patch('apps.accounts.resume_parser._enabled_layers',
176
+ return_value=['nucha', 'lexical']):
177
+ hits, _ = _predict_skills_from_text("Python project")
178
+
179
+ py_id = catalog['python'].id
180
+ # Nucha's 0.92 (uncalibrated ×1.0) beats lexical's 0.9025 (0.95 ×0.95).
181
+ assert hits[py_id]['confidence'] == 0.92
182
+
183
+
184
+ def test_parser_version_only_lists_layers_that_returned_hits(catalog):
185
+ """A layer that runs but returns {} is NOT in parser_version."""
186
+ from apps.accounts.resume_parser import _predict_skills_from_text
187
+
188
+ layers = {
189
+ 'nucha': _make_layer('nucha', {}),
190
+ 'lexical': _make_layer('lexical', {'Python': 0.95}),
191
+ }
192
+ with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \
193
+ patch('apps.accounts.resume_parser._enabled_layers',
194
+ return_value=['nucha', 'lexical']):
195
+ _, fired = _predict_skills_from_text("Python")
196
+ assert fired == ['lexical']
apps/accounts/tests/test_ner_layers.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-layer unit tests for Module 8 NER layers.
2
+
3
+ Each layer's heavy model is mocked so CI doesn't pull ~1 GB of BERT weights.
4
+ Runtime integration with real models is covered by
5
+ test_resume_parser_integration.py (gated on GAPGUIDE_ML_SMOKE=1).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ import pytest
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Nucha layer
16
+ # ---------------------------------------------------------------------------
17
+ class TestNuchaLayer:
18
+ def test_predict_extracts_hard_and_soft_skills(self):
19
+ """Pipeline returns HSKILL/SSKILL spans → alias dict keyed on span."""
20
+ from apps.accounts.ner import nucha
21
+
22
+ fake_pipeline = MagicMock(return_value=[
23
+ {'entity_group': 'HSKILL', 'score': 0.93, 'word': 'Python',
24
+ 'start': 0, 'end': 6},
25
+ {'entity_group': 'HSKILL', 'score': 0.88, 'word': 'SQL',
26
+ 'start': 10, 'end': 13},
27
+ {'entity_group': 'SSKILL', 'score': 0.71, 'word': 'teamwork',
28
+ 'start': 20, 'end': 28},
29
+ # Non-skill tokens must be dropped.
30
+ {'entity_group': 'O', 'score': 0.99, 'word': 'and',
31
+ 'start': 14, 'end': 17},
32
+ ])
33
+
34
+ with patch.object(nucha, '_pipeline', fake_pipeline):
35
+ out = nucha.layer.predict("Python SQL and teamwork")
36
+
37
+ assert out == {
38
+ 'Python': pytest.approx(0.93),
39
+ 'SQL': pytest.approx(0.88),
40
+ 'teamwork': pytest.approx(0.71),
41
+ }
42
+
43
+ def test_empty_text_returns_empty(self):
44
+ from apps.accounts.ner import nucha
45
+ with patch.object(nucha, '_pipeline', MagicMock()) as m:
46
+ assert nucha.layer.predict("") == {}
47
+ m.assert_not_called() # shortcut before the pipeline runs
48
+
49
+ def test_duplicate_spans_keep_max_confidence(self):
50
+ from apps.accounts.ner import nucha
51
+ fake = MagicMock(return_value=[
52
+ {'entity_group': 'HSKILL', 'score': 0.5, 'word': 'Python'},
53
+ {'entity_group': 'HSKILL', 'score': 0.9, 'word': 'Python'},
54
+ {'entity_group': 'HSKILL', 'score': 0.7, 'word': 'Python'},
55
+ ])
56
+ with patch.object(nucha, '_pipeline', fake):
57
+ out = nucha.layer.predict("Python Python Python")
58
+ assert out == {'Python': pytest.approx(0.9)}
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # JobBERT layer
63
+ # ---------------------------------------------------------------------------
64
+ class TestJobBertLayer:
65
+ def test_predict_accepts_bio_labels(self):
66
+ from apps.accounts.ner import jobbert
67
+
68
+ fake = MagicMock(return_value=[
69
+ {'entity_group': 'Skill', 'score': 0.87, 'word': 'React'},
70
+ {'entity_group': 'Skill', 'score': 0.81, 'word': 'Docker'},
71
+ # Non-skill label dropped.
72
+ {'entity_group': 'O', 'score': 0.99, 'word': 'the'},
73
+ ])
74
+ with patch.object(jobbert, '_pipeline', fake):
75
+ out = jobbert.layer.predict("React Docker the project")
76
+ assert out == {'React': pytest.approx(0.87), 'Docker': pytest.approx(0.81)}
77
+
78
+ def test_label_casing_is_ignored(self):
79
+ from apps.accounts.ner import jobbert
80
+ fake = MagicMock(return_value=[
81
+ {'entity_group': 'SKILL', 'score': 0.9, 'word': 'AWS'},
82
+ {'entity_group': 'skill', 'score': 0.8, 'word': 'GCP'},
83
+ ])
84
+ with patch.object(jobbert, '_pipeline', fake):
85
+ out = jobbert.layer.predict("AWS GCP")
86
+ assert 'AWS' in out and 'GCP' in out
87
+
88
+ def test_multitoken_skill_is_merged_via_offsets(self):
89
+ """F-NER-FRAG: with character offsets, a skill's B token + adjacent I
90
+ tokens re-stitch into one term (jjzha's suffix-less BIO labels stop
91
+ aggregation_strategy='simple' from doing this). Two separate B-started
92
+ skills stay distinct even when adjacent."""
93
+ from apps.accounts.ner import jobbert
94
+
95
+ text = "machine learning and Python"
96
+ fake = MagicMock(return_value=[
97
+ {'entity_group': 'B', 'score': 0.90, 'word': 'machine',
98
+ 'start': 0, 'end': 7},
99
+ {'entity_group': 'I', 'score': 0.80, 'word': 'learning',
100
+ 'start': 8, 'end': 16}, # adjacent (gap == 1) → merges
101
+ {'entity_group': 'B', 'score': 0.95, 'word': 'Python',
102
+ 'start': 21, 'end': 27}, # new B → stays separate
103
+ ])
104
+ with patch.object(jobbert, '_pipeline', fake):
105
+ out = jobbert.layer.predict(text)
106
+ assert out == {
107
+ 'machine learning': pytest.approx(0.80), # min score of the group
108
+ 'Python': pytest.approx(0.95),
109
+ }
110
+
111
+ def test_adjacent_begin_spans_stay_separate(self):
112
+ """Two B-tagged skills with only a space between them are NOT merged —
113
+ a 'B' always starts a fresh term."""
114
+ from apps.accounts.ner import jobbert
115
+
116
+ fake = MagicMock(return_value=[
117
+ {'entity_group': 'B', 'score': 0.9, 'word': 'Python',
118
+ 'start': 0, 'end': 6},
119
+ {'entity_group': 'B', 'score': 0.85, 'word': 'SQL',
120
+ 'start': 7, 'end': 10},
121
+ ])
122
+ with patch.object(jobbert, '_pipeline', fake):
123
+ out = jobbert.layer.predict("Python SQL")
124
+ assert out == {'Python': pytest.approx(0.9), 'SQL': pytest.approx(0.85)}
125
+
126
+ def test_gap_greater_than_one_keeps_tokens_separate(self):
127
+ """F-NER-FRAG boundary: a B token and a following I token separated by
128
+ more than one char (gap == 2) do NOT merge — pins the upper bound of
129
+ the continuation predicate (`0 <= start - cur_end <= 1`)."""
130
+ from apps.accounts.ner import jobbert
131
+
132
+ text = "machine learning" # two spaces → 2-char gap between the spans
133
+ fake = MagicMock(return_value=[
134
+ {'entity_group': 'B', 'score': 0.90, 'word': 'machine',
135
+ 'start': 0, 'end': 7},
136
+ {'entity_group': 'I', 'score': 0.80, 'word': 'learning',
137
+ 'start': 9, 'end': 17}, # gap == 2 → stays separate
138
+ ])
139
+ with patch.object(jobbert, '_pipeline', fake):
140
+ out = jobbert.layer.predict(text)
141
+ assert out == {
142
+ 'machine': pytest.approx(0.90),
143
+ 'learning': pytest.approx(0.80),
144
+ }
145
+
146
+ def test_bare_bio_prefixes_are_kept(self):
147
+ """Regression guard for the label-filter bug in jobbert.py.
148
+
149
+ The real jjzha/jobbert_skill_extraction model, under
150
+ aggregation_strategy="simple", emits entity_group as bare BIO
151
+ prefixes ("B" / "I") rather than the entity name. The old filter
152
+ (`"skill" not in label`) dropped every span. Since the model has
153
+ exactly one entity type (SKILL), every non-O span IS a skill.
154
+ """
155
+ from apps.accounts.ner import jobbert
156
+
157
+ fake = MagicMock(return_value=[
158
+ {'entity_group': 'B', 'score': 0.91, 'word': 'Python'},
159
+ {'entity_group': 'I', 'score': 0.76, 'word': 'Kubernetes'},
160
+ # Outside-span must still be dropped.
161
+ {'entity_group': 'O', 'score': 0.99, 'word': 'the'},
162
+ ])
163
+ with patch.object(jobbert, '_pipeline', fake):
164
+ out = jobbert.layer.predict("Python and Kubernetes the prod cluster")
165
+ assert out == {
166
+ 'Python': pytest.approx(0.91),
167
+ 'Kubernetes': pytest.approx(0.76),
168
+ }
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # SBERT layer — DB-backed via SkillEmbedding + pgvector CosineDistance
173
+ # ---------------------------------------------------------------------------
174
+ class TestSbertLayer:
175
+ """Unit-level: we don't want to hit pgvector here. Stub the ORM path and
176
+ verify the layer's own logic (threshold + aggregation)."""
177
+
178
+ def test_below_threshold_is_dropped(self):
179
+ import numpy as np
180
+
181
+ from apps.accounts.ner import sbert
182
+
183
+ # Stub noun phrase extraction to avoid loading spacy.
184
+ with patch.object(sbert, '_candidate_phrases', return_value=['banana']):
185
+ fake_model = MagicMock()
186
+ # Real SentenceTransformer.encode returns a numpy ndarray; the
187
+ # layer calls .tolist() on it, so mirror the shape here.
188
+ fake_model.encode = MagicMock(return_value=np.array([[0.1] * 384]))
189
+ with patch.object(sbert, '_get_model', return_value=fake_model):
190
+ # pgvector query returns a match whose cosine distance is too
191
+ # high (similarity = 1 - 0.5 = 0.5 < 0.65 threshold).
192
+ fake_emb = MagicMock()
193
+ fake_emb.distance = 0.5
194
+ fake_emb.skill.skill_name = 'Python'
195
+ fake_qs = MagicMock()
196
+ fake_qs.annotate.return_value.order_by.return_value.select_related.return_value.first.return_value = fake_emb
197
+
198
+ with patch('apps.skills.models.SkillEmbedding.objects', fake_qs):
199
+ out = sbert.layer.predict("I like banana smoothies")
200
+
201
+ assert out == {} # below threshold → dropped
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Lexical floor — already covered by test_resume_parser.py; just confirm the
206
+ # layer wrapper returns the expected shape.
207
+ # ---------------------------------------------------------------------------
208
+ @pytest.mark.django_db
209
+ class TestLexicalLayer:
210
+ def test_wraps_existing_candidate_matches(self):
211
+ from apps.accounts.ner import lexical
212
+ from apps.skills.models import Skill
213
+
214
+ Skill.objects.create(skill_name='Python', category='Programming',
215
+ difficulty_level='BEGINNER')
216
+ Skill.objects.create(skill_name='SQL', category='Database',
217
+ difficulty_level='BEGINNER')
218
+
219
+ out = lexical.layer.predict("Strong Python and SQL background.")
220
+ assert set(out) == {'Python', 'SQL'}
221
+ assert all(0 < v <= 1.0 for v in out.values())
apps/accounts/tests/test_profile.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from rest_framework.test import APIClient
3
+ from apps.skills.models import Skill
4
+
5
+ pytestmark = pytest.mark.django_db
6
+
7
+ REGISTER_URL = '/api/auth/register/'
8
+ LOGIN_URL = '/api/auth/login/'
9
+ PROFILE_URL = '/api/auth/profile/'
10
+ PARSE_RESUME_URL = '/api/auth/profile/parse-resume/'
11
+ SKILLS_URL = '/api/skills/'
12
+ USER_SKILLS_URL = '/api/user-skills/'
13
+
14
+ VALID_USER = {
15
+ 'name': 'Test User',
16
+ 'email': 'test2@example.com',
17
+ 'password': 'TestPass123!',
18
+ 'password_confirm': 'TestPass123!'
19
+ }
20
+
21
+
22
+ @pytest.fixture
23
+ def api_client():
24
+ return APIClient()
25
+
26
+
27
+ @pytest.fixture
28
+ def skill(db):
29
+ return Skill.objects.create(
30
+ skill_name='Python',
31
+ category='Programming',
32
+ difficulty_level='BEGINNER'
33
+ )
34
+
35
+
36
+ @pytest.fixture
37
+ def auth_client(api_client):
38
+ api_client.post(REGISTER_URL, data=VALID_USER, format='json')
39
+ login_resp = api_client.post(
40
+ LOGIN_URL,
41
+ data={'email': VALID_USER['email'], 'password': VALID_USER['password']},
42
+ format='json'
43
+ )
44
+ token = login_resp.data['access']
45
+ api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
46
+ return api_client
47
+
48
+
49
+ class TestProfile:
50
+ def test_get_profile(self, auth_client):
51
+ """TC-05: GET /profile/ returns profile + skills."""
52
+ response = auth_client.get(PROFILE_URL)
53
+ assert response.status_code == 200
54
+ assert 'user' in response.data
55
+ assert 'profile' in response.data
56
+ assert 'skills' in response.data
57
+
58
+ def test_update_profile(self, auth_client):
59
+ """TC-05: PUT /profile/ updates degree, semester, bio."""
60
+ data = {
61
+ 'degree': 'BS Computer Science',
62
+ 'semester': 6,
63
+ 'bio': 'I love coding',
64
+ 'interests': 'AI, Web Dev'
65
+ }
66
+ response = auth_client.put(PROFILE_URL, data=data, format='json')
67
+ assert response.status_code == 200
68
+ assert response.data['profile']['degree'] == 'BS Computer Science'
69
+ assert response.data['profile']['semester'] == 6
70
+
71
+ def test_update_profile_with_skills(self, auth_client, skill):
72
+ """TC-06: PUT /profile/ with skills upserts UserSkills."""
73
+ data = {
74
+ 'degree': 'BS CS',
75
+ 'skills': [
76
+ {'skill_id': skill.id, 'proficiency': 65}
77
+ ]
78
+ }
79
+ response = auth_client.put(PROFILE_URL, data=data, format='json')
80
+ assert response.status_code == 200
81
+ skills = response.data['skills']
82
+ assert len(skills) == 1
83
+ assert skills[0]['proficiency'] == 65
84
+ assert skills[0]['user_level'] == 'ADVANCED' # 65 > 60
85
+
86
+ def test_user_level_auto_computed(self, auth_client, skill):
87
+ """TC-06: user_level is auto-derived from proficiency."""
88
+ data = {'skills': [{'skill_id': skill.id, 'proficiency': 30}]}
89
+ response = auth_client.put(PROFILE_URL, data=data, format='json')
90
+ assert response.status_code == 200
91
+ assert response.data['skills'][0]['user_level'] == 'BEGINNER' # 30 <= 40
92
+
93
+ def test_profile_requires_auth(self, api_client):
94
+ """Profile endpoints require authentication."""
95
+ response = api_client.get(PROFILE_URL)
96
+ assert response.status_code == 401
97
+
98
+
99
+ class TestSkillCatalog:
100
+ def test_list_skills(self, auth_client, skill):
101
+ """GET /skills/ returns skill catalog (paginated)."""
102
+ response = auth_client.get(SKILLS_URL)
103
+ assert response.status_code == 200
104
+ assert response.data['count'] >= 1
105
+
106
+ def test_filter_by_category(self, auth_client, skill):
107
+ """GET /skills/?category=Programming filters skills."""
108
+ response = auth_client.get(f'{SKILLS_URL}?category=Programming')
109
+ assert response.status_code == 200
110
+ assert all(s['category'] == 'Programming' for s in response.data['results'])
111
+
112
+
113
+ class TestUserSkills:
114
+ def test_add_user_skill(self, auth_client, skill):
115
+ """POST /user-skills/ adds a skill to user."""
116
+ data = {'skill_id': skill.id, 'proficiency': 50}
117
+ response = auth_client.post(USER_SKILLS_URL, data=data, format='json')
118
+ assert response.status_code == 201
119
+ assert response.data['proficiency'] == 50
120
+ assert response.data['user_level'] == 'INTERMEDIATE'
121
+
122
+ def test_update_user_skill(self, auth_client, skill):
123
+ """PUT /user-skills/<id>/ updates proficiency."""
124
+ # First add
125
+ add_resp = auth_client.post(
126
+ USER_SKILLS_URL, data={'skill_id': skill.id, 'proficiency': 30}, format='json'
127
+ )
128
+ skill_id = add_resp.data['id']
129
+ # Then update
130
+ update_resp = auth_client.put(
131
+ f'{USER_SKILLS_URL}{skill_id}/',
132
+ data={'skill_id': skill.id, 'proficiency': 70},
133
+ format='json'
134
+ )
135
+ assert update_resp.status_code == 200
136
+ assert update_resp.data['user_level'] == 'ADVANCED'
137
+
138
+
139
+ class TestParseResumeStub:
140
+ """The Module 2 "stub" endpoint is now the real Module 8 MVP endpoint.
141
+ Kept under this class name for continuity — full happy-path + edge-case
142
+ coverage lives in apps/accounts/tests/test_resume_parser.py.
143
+ """
144
+
145
+ def test_requires_auth(self, api_client):
146
+ resp = api_client.post(PARSE_RESUME_URL, data={}, format='json')
147
+ assert resp.status_code == 401
148
+
149
+ def test_empty_body_returns_400(self, auth_client):
150
+ resp = auth_client.post(PARSE_RESUME_URL, data={}, format='multipart')
151
+ assert resp.status_code == 400
152
+ assert 'detail' in resp.data
apps/accounts/tests/test_resume_parser.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module 8 MVP tests — lexical resume parser.
2
+
3
+ Covers both the pure service function (extract_skills_from_pdf) and the HTTP
4
+ endpoint that wraps it. PDFs are built in-memory with pdfplumber's underlying
5
+ writer (reportlab isn't a dep, so we fake a minimal PDF byte stream via
6
+ pdfminer). For robustness we also lean on pdfplumber itself to round-trip a
7
+ known good fixture from pypdfium2's test payloads when possible — but the
8
+ happy path here avoids extra deps.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import io
13
+
14
+ import pytest
15
+ from django.contrib.auth import get_user_model
16
+ from rest_framework.test import APIClient
17
+
18
+ from apps.accounts.resume_parser import (
19
+ ResumeParseError,
20
+ extract_skills_from_pdf,
21
+ )
22
+ from apps.skills.models import Skill
23
+
24
+
25
+ User = get_user_model()
26
+ pytestmark = pytest.mark.django_db
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # PDF building — use pdfplumber's underlying pdfminer / pypdfium2 stack.
31
+ # We render a minimal PDF with reportlab when available, else fall back to a
32
+ # raw PDF byte stream crafted by hand. Both paths yield a PDF that pdfplumber
33
+ # can extract_text() cleanly from.
34
+ # ---------------------------------------------------------------------------
35
+ def _build_pdf(text: str) -> bytes:
36
+ """Return a minimal valid single-page PDF containing `text`.
37
+
38
+ Uses pypdfium2 (which pdfplumber already depends on) to render text.
39
+ If rendering isn't available in this env we hand-craft a PDF — slower
40
+ to maintain but avoids adding a new dependency just for tests.
41
+ """
42
+ try:
43
+ # Preferred path: rely on a known-good PDF template from pypdfium2's
44
+ # bundled samples. If nothing ships, fall through to the hand-written
45
+ # variant.
46
+ import pypdfium2 as pdfium # noqa: F401 — proves the dep is live
47
+ except Exception:
48
+ pass
49
+
50
+ # Hand-written minimal PDF — valid per the PDF 1.4 spec and parseable by
51
+ # pdfminer (pdfplumber's text extractor).
52
+ safe = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
53
+ pdf = (
54
+ b"%PDF-1.4\n"
55
+ b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
56
+ b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
57
+ b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]"
58
+ b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj\n"
59
+ )
60
+ stream = (
61
+ "BT /F1 12 Tf 50 750 Td (" + safe + ") Tj ET"
62
+ ).encode("latin-1", errors="replace")
63
+ pdf += (
64
+ b"4 0 obj<</Length " + str(len(stream)).encode() + b">>stream\n" +
65
+ stream + b"\nendstream endobj\n"
66
+ )
67
+ pdf += (
68
+ b"5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
69
+ b"xref\n0 6\n"
70
+ b"0000000000 65535 f \n"
71
+ )
72
+ # xref entries — rough, but sufficient for pdfminer tolerant parsing.
73
+ pdf += b"0000000010 00000 n \n" * 5
74
+ pdf += (
75
+ b"trailer<</Size 6/Root 1 0 R>>\n"
76
+ b"startxref\n"
77
+ b"0\n"
78
+ b"%%EOF\n"
79
+ )
80
+ return pdf
81
+
82
+
83
+ @pytest.fixture
84
+ def catalog():
85
+ py = Skill.objects.create(
86
+ skill_name='Python', category='Programming', difficulty_level='BEGINNER')
87
+ sql = Skill.objects.create(
88
+ skill_name='SQL', category='Database', difficulty_level='BEGINNER')
89
+ ml = Skill.objects.create(
90
+ skill_name='Machine Learning', category='AI',
91
+ difficulty_level='INTERMEDIATE')
92
+ react = Skill.objects.create(
93
+ skill_name='React', category='Frontend', difficulty_level='INTERMEDIATE')
94
+ return {'python': py, 'sql': sql, 'ml': ml, 'react': react}
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Pure service tests
99
+ # ---------------------------------------------------------------------------
100
+ class TestExtractSkillsFromPdf:
101
+ def test_empty_bytes_raises(self, catalog):
102
+ with pytest.raises(ResumeParseError):
103
+ extract_skills_from_pdf(b"")
104
+
105
+ def test_oversized_raises(self, catalog):
106
+ with pytest.raises(ResumeParseError):
107
+ extract_skills_from_pdf(b"x" * (6 * 1024 * 1024))
108
+
109
+ def test_corrupt_pdf_raises_user_error(self, catalog):
110
+ with pytest.raises(ResumeParseError):
111
+ extract_skills_from_pdf(b"not a pdf at all")
112
+
113
+ def test_exact_skill_names_extracted(self, catalog):
114
+ pdf = _build_pdf(
115
+ "Experienced developer with Python, SQL and React expertise. "
116
+ "5 years of Machine Learning experience."
117
+ )
118
+ predictions = extract_skills_from_pdf(pdf)
119
+ names = {p['skill_name'] for p in predictions}
120
+ # At least the catalog skills we planted should show up.
121
+ assert {'Python', 'SQL', 'Machine Learning', 'React'} <= names
122
+
123
+ def test_experience_markers_boost_proficiency(self, catalog):
124
+ pdf = _build_pdf(
125
+ "Expert Python developer with 8 years of experience. Basic SQL."
126
+ )
127
+ predictions = extract_skills_from_pdf(pdf)
128
+ by_name = {p['skill_name']: p for p in predictions}
129
+ # Python context has "Expert" + "years" → top bucket
130
+ # SQL context has "Basic" → mid-low bucket
131
+ assert by_name['Python']['proficiency'] >= by_name['SQL']['proficiency']
132
+
133
+ def test_results_sorted_by_confidence_desc(self, catalog):
134
+ pdf = _build_pdf("Python SQL React Machine Learning")
135
+ predictions = extract_skills_from_pdf(pdf)
136
+ confidences = [p['confidence'] for p in predictions]
137
+ assert confidences == sorted(confidences, reverse=True)
138
+
139
+ def test_unknown_skills_not_created(self, catalog):
140
+ before = Skill.objects.count()
141
+ pdf = _build_pdf(
142
+ "Brainfuck and Whitespace and Malbolge are my specialties."
143
+ )
144
+ _ = extract_skills_from_pdf(pdf)
145
+ assert Skill.objects.count() == before
146
+
147
+ def test_no_matches_returns_empty_list(self, catalog):
148
+ pdf = _build_pdf("I love woodworking and baking sourdough bread.")
149
+ predictions = extract_skills_from_pdf(pdf)
150
+ # "bread" might false-positive via fuzzy on short names, so only
151
+ # assert there's no catalog name returned.
152
+ names = {p['skill_name'] for p in predictions}
153
+ assert names.isdisjoint({'Python', 'SQL', 'React', 'Machine Learning'})
154
+
155
+ def test_predictions_carry_matched_span_for_exact_hits(self, catalog):
156
+ pdf = _build_pdf(
157
+ "Built ML pipelines. Strong Python and React background."
158
+ )
159
+ predictions = extract_skills_from_pdf(pdf)
160
+ py = next(p for p in predictions if p['skill_name'] == 'Python')
161
+ assert 'python' in py['matched_span'].lower()
162
+
163
+ def test_proficiency_aggregates_across_windows(self, catalog):
164
+ """When a skill appears multiple times with conflicting signals, the
165
+ strongest claim wins — not the first occurrence.
166
+
167
+ Here Python appears twice: first as "beginner Python" (NOVICE=40) then
168
+ as "lead Python team" (STRONG=70). Under the pre-#11 single-window
169
+ logic the first match won → proficiency 40. With multi-window
170
+ aggregation the STRONG signal should prevail → proficiency 70.
171
+ """
172
+ pdf = _build_pdf(
173
+ "Learning path started as a beginner Python student. "
174
+ "Now lead Python team of five engineers building data platforms."
175
+ )
176
+ predictions = extract_skills_from_pdf(pdf)
177
+ py = next(p for p in predictions if p['skill_name'] == 'Python')
178
+ assert py['proficiency'] == 70
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # HTTP endpoint tests
183
+ # ---------------------------------------------------------------------------
184
+ def _auth_client():
185
+ user = User.objects.create_user(
186
+ username='parser@x.com', email='parser@x.com',
187
+ password='StrongPass123!', name='Parser',
188
+ )
189
+ c = APIClient()
190
+ c.force_authenticate(user=user)
191
+ return c
192
+
193
+
194
+ class TestParseResumeEndpoint:
195
+ URL = '/api/auth/profile/parse-resume/'
196
+
197
+ def test_requires_authentication(self):
198
+ c = APIClient()
199
+ r = c.post(self.URL, data={}, format='multipart')
200
+ assert r.status_code == 401
201
+
202
+ def test_empty_body_returns_400(self, catalog):
203
+ c = _auth_client()
204
+ r = c.post(self.URL, data={}, format='multipart')
205
+ assert r.status_code == 400
206
+ assert 'resume' in r.data['detail'].lower() or \
207
+ 'pdf' in r.data['detail'].lower()
208
+
209
+ def test_empty_file_returns_400(self, catalog):
210
+ c = _auth_client()
211
+ upload = io.BytesIO(b"")
212
+ upload.name = 'empty.pdf'
213
+ r = c.post(self.URL, data={'resume': upload}, format='multipart')
214
+ assert r.status_code == 400
215
+
216
+ def test_corrupt_pdf_returns_400(self, catalog):
217
+ c = _auth_client()
218
+ upload = io.BytesIO(b"not a pdf")
219
+ upload.name = 'garbage.pdf'
220
+ r = c.post(self.URL, data={'resume': upload}, format='multipart')
221
+ assert r.status_code == 400
222
+
223
+ def test_happy_path_returns_skills(self, catalog):
224
+ c = _auth_client()
225
+ upload = io.BytesIO(_build_pdf(
226
+ "Senior Python developer. Deep Machine Learning background. "
227
+ "React on the frontend. 7 years experience."
228
+ ))
229
+ upload.name = 'resume.pdf'
230
+ r = c.post(self.URL, data={'resume': upload}, format='multipart')
231
+ assert r.status_code == 200
232
+ body = r.data
233
+ assert 'skills' in body
234
+ # parser_version is now a list of layer names (e.g. ['lexical']).
235
+ # Under the CI pin GAPGUIDE_PARSE_LAYERS=lexical the floor is the
236
+ # only layer firing.
237
+ assert isinstance(body['parser_version'], list)
238
+ assert 'lexical' in body['parser_version']
239
+ names = {s['skill_name'] for s in body['skills']}
240
+ # At least Python and React should land — those are unambiguous.
241
+ assert 'Python' in names
242
+ assert 'React' in names
243
+
244
+ def test_no_state_mutation(self, catalog):
245
+ """Parsing is read-only — must not create UserSkill or Skill rows."""
246
+ from apps.skills.models import UserSkill
247
+ c = _auth_client()
248
+ skill_count = Skill.objects.count()
249
+ us_count = UserSkill.objects.count()
250
+ upload = io.BytesIO(_build_pdf("Python Java React"))
251
+ upload.name = 'resume.pdf'
252
+ c.post(self.URL, data={'resume': upload}, format='multipart')
253
+ assert Skill.objects.count() == skill_count
254
+ assert UserSkill.objects.count() == us_count
apps/accounts/tests/test_resume_parser_integration.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end NER chain integration test.
2
+
3
+ Gated on ``GAPGUIDE_ML_SMOKE=1`` — runs the REAL four-layer chain
4
+ (Nucha + JobBERT + SkillNER + SBERT + lexical) against a fixture PDF. CI
5
+ sets GAPGUIDE_PARSE_LAYERS=lexical (see conftest.py), so this opt-in test
6
+ is the only place we verify the ML stack actually works end-to-end.
7
+
8
+ Run manually:
9
+ $env:GAPGUIDE_ML_SMOKE="1"
10
+ pytest apps/accounts/tests/test_resume_parser_integration.py -q
11
+
12
+ First run downloads ~1 GB of HF models + requires `en_core_web_lg`
13
+ installed and pgvector extension in Postgres with SkillEmbedding populated.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+
23
+ pytestmark = [
24
+ pytest.mark.django_db(transaction=True),
25
+ pytest.mark.skipif(
26
+ os.environ.get("GAPGUIDE_ML_SMOKE") != "1",
27
+ reason="ML smoke disabled (set GAPGUIDE_ML_SMOKE=1 to enable)",
28
+ ),
29
+ ]
30
+
31
+
32
+ FIX = Path(__file__).resolve().parent.parent.parent.parent / 'tests' / 'fixtures' / 'resumes'
33
+
34
+
35
+ @pytest.fixture
36
+ def seeded_catalog():
37
+ """Real catalog + SBERT embeddings. Requires:
38
+ * pgvector extension on the test DB
39
+ * en_core_web_lg installed
40
+ * seed_initial_skills has been run (we do it here)
41
+ """
42
+ from django.core.management import call_command
43
+ call_command('seed_initial_skills')
44
+
45
+ # Build embeddings for the seeded skills.
46
+ from apps.skills.models import Skill, SkillEmbedding
47
+ from sentence_transformers import SentenceTransformer
48
+ model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
49
+ skills = list(Skill.objects.all())
50
+ vectors = model.encode(
51
+ [f"{s.skill_name} — {s.description or s.category}" for s in skills],
52
+ normalize_embeddings=True,
53
+ )
54
+ for skill, vec in zip(skills, vectors):
55
+ SkillEmbedding.objects.update_or_create(
56
+ skill=skill,
57
+ defaults={
58
+ 'embedding': vec.tolist(),
59
+ 'source_text': skill.skill_name,
60
+ },
61
+ )
62
+ return skills
63
+
64
+
65
+ def test_full_chain_extracts_core_skills_from_strong_fixture(seeded_catalog):
66
+ """resume_ds_strong.pdf must yield at least {Python, SQL} via the full chain.
67
+
68
+ We deliberately don't assert exact counts / confidences — those depend
69
+ on the specific HF model versions and would flake across upgrades.
70
+ """
71
+ # Force the full chain.
72
+ os.environ.pop('GAPGUIDE_PARSE_LAYERS', None)
73
+ from apps.accounts.resume_parser import parse_resume_envelope
74
+
75
+ pdf = (FIX / 'resume_ds_strong.pdf').read_bytes()
76
+ env = parse_resume_envelope(pdf)
77
+
78
+ names = {s['skill_name'] for s in env['skills']}
79
+ assert {'Python', 'SQL'}.issubset(names), (env['parser_version'], names)
80
+
81
+ # At least one BERT/SBERT/SkillNER layer must have fired — proves the
82
+ # chain isn't silently falling back to lexical-only.
83
+ assert any(
84
+ layer in env['parser_version']
85
+ for layer in ('nucha', 'jobbert', 'skillner', 'sbert')
86
+ ), env['parser_version']
87
+
88
+
89
+ def test_minimal_fixture_exercises_sbert_fallback(seeded_catalog):
90
+ """resume_minimal.pdf uses paraphrased skills ("data visualisation",
91
+ "version control"). Lexical layer alone returns nothing; the SBERT
92
+ fallback should resolve at least one paraphrase to a catalog skill.
93
+ """
94
+ os.environ.pop('GAPGUIDE_PARSE_LAYERS', None)
95
+ from apps.accounts.resume_parser import parse_resume_envelope
96
+
97
+ pdf = (FIX / 'resume_minimal.pdf').read_bytes()
98
+ env = parse_resume_envelope(pdf)
99
+
100
+ # Either SBERT or SkillNER should salvage at least one catalog hit here —
101
+ # otherwise the fallback chain isn't doing the job it was designed for.
102
+ assert len(env['skills']) >= 1, (env['parser_version'], env['skills'])
apps/accounts/urls.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.urls import path
2
+ from rest_framework_simplejwt.views import TokenRefreshView
3
+ from .views import (
4
+ RegisterView, LoginView, LogoutView, UserMeView,
5
+ ProfileView, ParseResumeStubView,
6
+ )
7
+
8
+ urlpatterns = [
9
+ path('register/', RegisterView.as_view(), name='register'),
10
+ path('login/', LoginView.as_view(), name='login'),
11
+ path('logout/', LogoutView.as_view(), name='logout'),
12
+ path('me/', UserMeView.as_view(), name='user-me'),
13
+ path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'),
14
+ path('profile/', ProfileView.as_view(), name='profile'),
15
+ path('profile/parse-resume/', ParseResumeStubView.as_view(), name='profile-parse-resume'),
16
+ ]
apps/accounts/views.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from drf_spectacular.types import OpenApiTypes
2
+ from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
3
+ from rest_framework import generics, permissions, serializers, status
4
+ from rest_framework.exceptions import ValidationError
5
+ from rest_framework.response import Response
6
+ from rest_framework.throttling import ScopedRateThrottle
7
+ from rest_framework.views import APIView
8
+ from rest_framework_simplejwt.exceptions import TokenError
9
+ from rest_framework_simplejwt.tokens import RefreshToken
10
+ from django.contrib.auth import get_user_model
11
+ from django.db import transaction, IntegrityError
12
+
13
+ from apps.skills.models import Skill, UserSkill
14
+ from apps.skills.utils import proficiency_to_level
15
+
16
+ from .models import UserProfile
17
+ from .serializers import (
18
+ LoginSerializer,
19
+ ProfileWithSkillsSerializer,
20
+ RegisterSerializer,
21
+ UserProfileSerializer,
22
+ UserSerializer,
23
+ )
24
+
25
+ User = get_user_model()
26
+
27
+
28
+ # Shape for endpoints that issue JWTs (register, login).
29
+ _AUTH_RESPONSE = inline_serializer(
30
+ name='AuthTokenResponse',
31
+ fields={
32
+ 'access': serializers.CharField(),
33
+ 'refresh': serializers.CharField(),
34
+ 'user': UserSerializer(),
35
+ },
36
+ )
37
+
38
+
39
+ @extend_schema(
40
+ responses={
41
+ 201: _AUTH_RESPONSE,
42
+ 400: OpenApiResponse(
43
+ description='Validation error — duplicate email, weak/mismatched '
44
+ 'password, or missing fields (body is {"<field>": [...]}).',
45
+ ),
46
+ },
47
+ )
48
+ class RegisterView(generics.CreateAPIView):
49
+ queryset = User.objects.all()
50
+ permission_classes = [permissions.AllowAny]
51
+ throttle_classes = [ScopedRateThrottle]
52
+ throttle_scope = 'register'
53
+ serializer_class = RegisterSerializer
54
+
55
+ def create(self, request, *args, **kwargs):
56
+ serializer = self.get_serializer(data=request.data)
57
+ serializer.is_valid(raise_exception=True)
58
+ try:
59
+ user = serializer.save()
60
+ except IntegrityError:
61
+ # Concurrent-registration race: two simultaneous case-variant emails
62
+ # can both pass the case-insensitive validate_email check; both then
63
+ # insert the SAME app-lowercased value, and the plain `unique=True`
64
+ # constraint on email/username rejects the loser. Surface a clean 400
65
+ # keyed on `email` (matching validate_email) instead of a 500.
66
+ return Response(
67
+ {'email': ['A user with this email already exists.']},
68
+ status=status.HTTP_400_BAD_REQUEST,
69
+ )
70
+
71
+ refresh = RefreshToken.for_user(user)
72
+ return Response({
73
+ 'access': str(refresh.access_token),
74
+ 'refresh': str(refresh),
75
+ 'user': UserSerializer(user).data
76
+ }, status=status.HTTP_201_CREATED)
77
+
78
+
79
+ @extend_schema(
80
+ request=LoginSerializer,
81
+ responses={200: _AUTH_RESPONSE},
82
+ )
83
+ class LoginView(APIView):
84
+ serializer_class = LoginSerializer
85
+ permission_classes = [permissions.AllowAny]
86
+ throttle_classes = [ScopedRateThrottle]
87
+ throttle_scope = 'login'
88
+
89
+ def post(self, request):
90
+ serializer = LoginSerializer(data=request.data, context={'request': request})
91
+ serializer.is_valid(raise_exception=True)
92
+ user = serializer.validated_data
93
+
94
+ refresh = RefreshToken.for_user(user)
95
+
96
+ return Response({
97
+ 'access': str(refresh.access_token),
98
+ 'refresh': str(refresh),
99
+ 'user': UserSerializer(user).data
100
+ }, status=status.HTTP_200_OK)
101
+
102
+
103
+ @extend_schema(
104
+ request=inline_serializer(
105
+ name='LogoutRequest',
106
+ fields={'refresh_token': serializers.CharField()},
107
+ ),
108
+ responses={
109
+ 204: OpenApiResponse(description='Token blacklisted.'),
110
+ 400: OpenApiResponse(description='Missing or invalid refresh token.'),
111
+ },
112
+ )
113
+ class LogoutView(APIView):
114
+ permission_classes = [permissions.IsAuthenticated]
115
+
116
+ def post(self, request):
117
+ try:
118
+ refresh_token = request.data.get('refresh_token')
119
+ if not refresh_token:
120
+ return Response(
121
+ {'detail': 'refresh_token is required'},
122
+ status=status.HTTP_400_BAD_REQUEST
123
+ )
124
+ token = RefreshToken(refresh_token)
125
+ token.blacklist()
126
+ return Response(status=status.HTTP_204_NO_CONTENT)
127
+ except TokenError:
128
+ return Response(
129
+ {'detail': 'Invalid or expired token'},
130
+ status=status.HTTP_400_BAD_REQUEST
131
+ )
132
+
133
+
134
+ class UserMeView(generics.RetrieveAPIView):
135
+ serializer_class = UserSerializer
136
+ permission_classes = [permissions.IsAuthenticated]
137
+
138
+ def get_object(self):
139
+ return self.request.user
140
+
141
+
142
+ @extend_schema(
143
+ request={
144
+ 'multipart/form-data': inline_serializer(
145
+ name='ResumeUpload',
146
+ fields={'resume': serializers.FileField()},
147
+ ),
148
+ },
149
+ responses={
150
+ 200: inline_serializer(
151
+ name='ParseResumeResponse',
152
+ fields={
153
+ 'skills': serializers.ListField(child=serializers.DictField()),
154
+ 'parser_version': serializers.ListField(child=serializers.CharField()),
155
+ 'note': serializers.CharField(),
156
+ },
157
+ ),
158
+ 400: OpenApiResponse(description='Empty / missing file or parse error.'),
159
+ },
160
+ )
161
+ class ParseResumeStubView(APIView):
162
+ """Resume parsing — Module 8 MVP.
163
+
164
+ Accepts a multipart PDF upload and returns pre-fill predictions scored
165
+ against the Skill catalog. This is the "degraded fallback" layer in the
166
+ research/06-dataset-sourcing.md §5 fallback chain — pdfplumber + lexical
167
+ match. Higher NER layers can be plugged in behind
168
+ `apps.accounts.resume_parser.extract_skills_from_pdf` when the ML
169
+ env-setup track (SpaCy/BERT wheels + pgvector) lands.
170
+
171
+ Class name kept as-is (`ParseResumeStubView`) so URLConf + existing routes
172
+ don't churn — the view is no longer a stub but the URL name is stable.
173
+ """
174
+ permission_classes = [permissions.IsAuthenticated]
175
+ throttle_classes = [ScopedRateThrottle]
176
+ throttle_scope = 'resume_parse'
177
+
178
+ def post(self, request):
179
+ upload = (
180
+ request.FILES.get('resume')
181
+ or request.FILES.get('file')
182
+ or next(iter(request.FILES.values()), None)
183
+ )
184
+ if upload is None:
185
+ return Response(
186
+ {"detail": "Upload a PDF file under the 'resume' form field."},
187
+ status=status.HTTP_400_BAD_REQUEST,
188
+ )
189
+ if upload.size == 0:
190
+ return Response(
191
+ {"detail": "Uploaded file is empty."},
192
+ status=status.HTTP_400_BAD_REQUEST,
193
+ )
194
+
195
+ from .resume_parser import ResumeParseError, parse_resume_envelope
196
+
197
+ try:
198
+ envelope = parse_resume_envelope(upload.read())
199
+ except ResumeParseError as exc:
200
+ return Response(
201
+ {"detail": str(exc)},
202
+ status=status.HTTP_400_BAD_REQUEST,
203
+ )
204
+
205
+ return Response(
206
+ {
207
+ "skills": envelope["skills"],
208
+ "parser_version": envelope["parser_version"],
209
+ "note": (
210
+ "Predictions are merged across the layers listed above. "
211
+ "Review the proficiency values — they are heuristics from "
212
+ "the surrounding context, not assertions."
213
+ ),
214
+ },
215
+ status=status.HTTP_200_OK,
216
+ )
217
+
218
+
219
+ class ProfileView(APIView):
220
+ serializer_class = ProfileWithSkillsSerializer
221
+ permission_classes = [permissions.IsAuthenticated]
222
+
223
+ @extend_schema(responses=ProfileWithSkillsSerializer)
224
+ def get(self, request):
225
+ user = request.user
226
+ UserProfile.objects.get_or_create(user=user)
227
+ serializer = ProfileWithSkillsSerializer(user)
228
+ return Response(serializer.data, status=status.HTTP_200_OK)
229
+
230
+ @extend_schema(
231
+ request=inline_serializer(
232
+ name='ProfileUpdateRequest',
233
+ fields={
234
+ 'degree': serializers.CharField(required=False, allow_blank=True),
235
+ 'semester': serializers.IntegerField(required=False, allow_null=True),
236
+ 'interests': serializers.CharField(required=False, allow_blank=True),
237
+ 'bio': serializers.CharField(required=False, allow_blank=True),
238
+ 'skills': serializers.ListField(
239
+ child=inline_serializer(
240
+ name='ProfileSkillItem',
241
+ fields={
242
+ 'skill_id': serializers.IntegerField(),
243
+ 'proficiency': serializers.IntegerField(min_value=0, max_value=100),
244
+ },
245
+ ),
246
+ required=False,
247
+ ),
248
+ },
249
+ ),
250
+ responses=ProfileWithSkillsSerializer,
251
+ )
252
+ @transaction.atomic
253
+ def put(self, request):
254
+ user = request.user
255
+ profile, _ = UserProfile.objects.get_or_create(user=user)
256
+
257
+ profile_serializer = UserProfileSerializer(
258
+ profile, data=request.data, partial=True
259
+ )
260
+ profile_serializer.is_valid(raise_exception=True)
261
+ profile_serializer.save()
262
+
263
+ # Upsert skills (transactional — any error rolls back all changes)
264
+ skills_data = request.data.get('skills', []) or []
265
+ requested_ids = {
266
+ int(item['skill_id']) for item in skills_data
267
+ if item.get('skill_id') is not None and item.get('proficiency') is not None
268
+ }
269
+ known_ids = set(
270
+ Skill.objects.filter(id__in=requested_ids).values_list('id', flat=True)
271
+ ) if requested_ids else set()
272
+
273
+ for item in skills_data:
274
+ skill_id = item.get('skill_id')
275
+ proficiency = item.get('proficiency')
276
+ if skill_id is None or proficiency is None:
277
+ continue
278
+ skill_id = int(skill_id)
279
+ proficiency = int(proficiency)
280
+ if not (0 <= proficiency <= 100):
281
+ raise ValidationError(f"Proficiency must be 0-100 for skill_id {skill_id}")
282
+ if skill_id not in known_ids:
283
+ raise ValidationError(f"Skill with id={skill_id} does not exist")
284
+ UserSkill.objects.update_or_create(
285
+ user=user, skill_id=skill_id,
286
+ defaults={
287
+ 'proficiency': proficiency,
288
+ 'user_level': proficiency_to_level(proficiency)
289
+ }
290
+ )
291
+
292
+ serializer = ProfileWithSkillsSerializer(user)
293
+ return Response(serializer.data, status=status.HTTP_200_OK)
apps/analysis/__init__.py ADDED
File without changes
apps/analysis/admin.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from django.contrib import admin
apps/analysis/apps.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class AnalysisConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'apps.analysis'
apps/analysis/models.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from django.db import models
apps/analysis/services.py ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gap Analysis + Recommendation services.
2
+
3
+ Gap analysis — Weighted Competency Fulfillment Algorithm.
4
+ See research/design-decision-changes.md §Module 4 for derivation. Pure functions,
5
+ no DB writes: the report is computed on demand from UserSkill + RoleSkill state.
6
+
7
+ Recommendations — composite ranking over the Resource catalog.
8
+ See research/08-next-modules-build-plan.md §Module A. Type weights from
9
+ research/03-gap-analysis-engine-research.md §2.1 are the single source of truth
10
+ for type preference.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import asdict, dataclass, field
15
+ from typing import Iterable
16
+
17
+ from apps.resources.models import Resource, SkillResource
18
+ from apps.roles.models import Role, RoleSkill
19
+ from apps.skills.models import UserSkill
20
+ from apps.skills.utils import proficiency_to_level
21
+
22
+
23
+ LEVEL_THRESHOLDS: dict[str, int] = {
24
+ 'BEGINNER': 40,
25
+ 'INTERMEDIATE': 60,
26
+ 'ADVANCED': 100,
27
+ }
28
+
29
+ MANDATORY_CAP = 60.0
30
+
31
+ BANDS: list[tuple[float, str]] = [
32
+ (85.0, 'STRONG'),
33
+ (65.0, 'GOOD'),
34
+ (45.0, 'PARTIAL'),
35
+ (0.0, 'WEAK'),
36
+ ]
37
+
38
+
39
+ @dataclass
40
+ class SkillGap:
41
+ skill_id: int
42
+ skill_name: str
43
+ category: str
44
+ required_level: str
45
+ threshold: int
46
+ user_proficiency: int
47
+ weight: float
48
+ is_mandatory: bool
49
+ gap: int
50
+ satisfaction: float
51
+ gap_type: str
52
+ severity: str | None
53
+
54
+ def to_dict(self) -> dict:
55
+ return asdict(self)
56
+
57
+
58
+ @dataclass
59
+ class GapReport:
60
+ role_id: int
61
+ role_name: str
62
+ readiness: float
63
+ band: str
64
+ mandatory_cap_applied: bool
65
+ no_requirements: bool
66
+ gaps: list[SkillGap] = field(default_factory=list)
67
+
68
+ def to_dict(self) -> dict:
69
+ return {
70
+ 'role_id': self.role_id,
71
+ 'role_name': self.role_name,
72
+ 'readiness': round(self.readiness, 2),
73
+ 'band': self.band,
74
+ 'mandatory_cap_applied': self.mandatory_cap_applied,
75
+ 'mandatory_cap': MANDATORY_CAP,
76
+ 'no_requirements': self.no_requirements,
77
+ 'gaps': [g.to_dict() for g in self.gaps],
78
+ }
79
+
80
+
81
+ def _band_for(readiness: float) -> str:
82
+ for floor, name in BANDS:
83
+ if readiness >= floor:
84
+ return name
85
+ return 'WEAK'
86
+
87
+
88
+ def _classify(
89
+ user_proficiency: int,
90
+ threshold: int,
91
+ is_mandatory: bool,
92
+ ) -> tuple[str, str | None]:
93
+ """Return (gap_type, severity) per §3 of design-decision-changes.md."""
94
+ if user_proficiency >= threshold:
95
+ return 'MET', None
96
+
97
+ if user_proficiency == 0:
98
+ return 'MISSING', 'CRITICAL' if is_mandatory else 'MEDIUM'
99
+
100
+ ratio = (threshold - user_proficiency) / threshold
101
+ if is_mandatory or ratio >= 0.5:
102
+ severity = 'HIGH'
103
+ elif ratio >= 0.25:
104
+ severity = 'MEDIUM'
105
+ else:
106
+ severity = 'LOW'
107
+ return 'INSUFFICIENT', severity
108
+
109
+
110
+ def _user_proficiency_map(user_id: int, skill_ids: Iterable[int]) -> dict[int, int]:
111
+ qs = UserSkill.objects.filter(user_id=user_id, skill_id__in=list(skill_ids))
112
+ return {us.skill_id: us.proficiency for us in qs}
113
+
114
+
115
+ def compute_gap_report(user, role: Role) -> GapReport:
116
+ role_skills = list(
117
+ RoleSkill.objects.filter(role=role).select_related('skill')
118
+ )
119
+
120
+ if not role_skills:
121
+ return GapReport(
122
+ role_id=role.id,
123
+ role_name=role.role_name,
124
+ readiness=100.0,
125
+ band=_band_for(100.0),
126
+ mandatory_cap_applied=False,
127
+ no_requirements=True,
128
+ )
129
+
130
+ prof_map = _user_proficiency_map(user.id, (rs.skill_id for rs in role_skills))
131
+
132
+ gaps: list[SkillGap] = []
133
+ weighted_sat_sum = 0.0
134
+ weight_sum = 0.0
135
+ mandatory_shortfall = False
136
+
137
+ for rs in role_skills:
138
+ threshold = LEVEL_THRESHOLDS[rs.required_level]
139
+ user_prof = prof_map.get(rs.skill_id, 0)
140
+ satisfaction = min(user_prof / threshold, 1.0)
141
+ gap_points = max(threshold - user_prof, 0)
142
+ gap_type, severity = _classify(user_prof, threshold, rs.is_mandatory)
143
+
144
+ if rs.is_mandatory and satisfaction < 1.0:
145
+ mandatory_shortfall = True
146
+
147
+ weight_sum += rs.weight
148
+ weighted_sat_sum += satisfaction * rs.weight
149
+
150
+ gaps.append(SkillGap(
151
+ skill_id=rs.skill_id,
152
+ skill_name=rs.skill.skill_name,
153
+ category=rs.skill.category,
154
+ required_level=rs.required_level,
155
+ threshold=threshold,
156
+ user_proficiency=user_prof,
157
+ weight=rs.weight,
158
+ is_mandatory=rs.is_mandatory,
159
+ gap=gap_points,
160
+ satisfaction=round(satisfaction, 4),
161
+ gap_type=gap_type,
162
+ severity=severity,
163
+ ))
164
+
165
+ if weight_sum <= 0:
166
+ # No positive weight to aggregate. With the RoleSkill DB CheckConstraints
167
+ # in force a mandatory skill always has weight>0, so weight_sum<=0 implies
168
+ # no mandatory shortfall and the documented zero-weight contract returns
169
+ # 100/no_requirements. The mandatory branch is defense-in-depth against a
170
+ # constraint-bypassing bulk write — it avoids a 0.0/0.0 ZeroDivisionError.
171
+ readiness = 0.0 if mandatory_shortfall else 100.0
172
+ return GapReport(
173
+ role_id=role.id,
174
+ role_name=role.role_name,
175
+ readiness=readiness,
176
+ band=_band_for(readiness),
177
+ # readiness here is 0 (mandatory unmet) or 100 (no requirements);
178
+ # neither is a value "capped down to 60", so the cap is not applied.
179
+ mandatory_cap_applied=False,
180
+ no_requirements=not mandatory_shortfall,
181
+ gaps=gaps,
182
+ )
183
+
184
+ raw_readiness = (weighted_sat_sum / weight_sum) * 100.0
185
+ cap_applied = mandatory_shortfall and raw_readiness > MANDATORY_CAP
186
+ readiness = MANDATORY_CAP if cap_applied else raw_readiness
187
+ readiness = max(0.0, min(readiness, 100.0))
188
+
189
+ gaps.sort(
190
+ key=lambda g: (
191
+ -{'CRITICAL': 4, 'HIGH': 3, 'MEDIUM': 2, 'LOW': 1, None: 0}[g.severity],
192
+ -g.weight,
193
+ g.skill_name,
194
+ )
195
+ )
196
+
197
+ return GapReport(
198
+ role_id=role.id,
199
+ role_name=role.role_name,
200
+ readiness=readiness,
201
+ band=_band_for(readiness),
202
+ mandatory_cap_applied=cap_applied,
203
+ no_requirements=False,
204
+ gaps=gaps,
205
+ )
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Recommendation service
210
+ # ---------------------------------------------------------------------------
211
+ #
212
+ # Recommendations are ranked by a TWO-KEYED order so that material which is
213
+ # clearly the wrong level can never leapfrog appropriately-levelled material on
214
+ # quality (type/relevance/rating) alone:
215
+ #
216
+ # 1. LEVEL TIER (primary) — the distance between the user's CURRENT level for
217
+ # the skill (proficiency_to_level(), NOT the role's required level) and the
218
+ # resource's difficulty. distance 0-1 = "near" (tier 0); distance >=2 =
219
+ # "far" (tier 1). Every near resource ranks above every far one. So a
220
+ # beginner always sees BEGINNER/INTERMEDIATE content before ADVANCED — a
221
+ # hard guarantee, independent of how good the far resource looks otherwise.
222
+ #
223
+ # 2. COMPOSITE QUALITY SCORE (secondary) — orders resources WITHIN a tier:
224
+ #
225
+ # score = TYPE_WEIGHT[type] * w_type
226
+ # + DIFFICULTY_MATCH(user_current_level, difficulty) * w_diff
227
+ # + relevance_score * 10 * w_rel # normalise to 0..10
228
+ # + (rating / 5.0) * 10 * w_rating # normalise to 0..10
229
+ #
230
+ # Within the near tier the difficulty term still gives exact-level a gentle
231
+ # edge over one-level-off, but it stays SOFT: a much more relevant/stronger
232
+ # one-level-off resource can still outrank a weak exact-level one. The only
233
+ # HARD rule is the near-over-far split above.
234
+ #
235
+ # Coefficients (single source of truth — keep in sync with the docstring above):
236
+ #
237
+ # w_type = 1.0 (dominant — follows the type preference from research
238
+ # §2.1 "Video > Course > Article > Docs")
239
+ # w_diff = 1.0
240
+ # w_rel = 1.5 (relevance is heavily weighted — a very-relevant DOCS
241
+ # can outrank a somewhat-relevant VIDEO at the same level)
242
+ # w_rating = 0.5
243
+ #
244
+ # Notable consequences, verified by unit tests:
245
+ # * Equal rating/relevance/difficulty → VIDEO > COURSE > ARTICLE > DOCS.
246
+ # * A resource <=1 level from the user ALWAYS ranks above one >=2 levels away,
247
+ # regardless of type/relevance/rating (the level-tier guarantee).
248
+ # * Within a tier, exact-level beats one-level-off on equal quality, yet a
249
+ # relevance_score=1.0 DOCS can outrank a relevance_score=0.3 VIDEO at the
250
+ # same level (relevance is weighted 1.5x), both at rating 3.5/5:
251
+ # DOCS : 4*1.0 + 10*1.0 + (1.0*10)*1.5 + (3.5/5*10)*0.5 = 32.5
252
+ # VIDEO: 10*1.0 + 10*1.0 + (0.3*10)*1.5 + (3.5/5*10)*0.5 = 28.0
253
+ # * Stable tie-break by resource_id ASC so API snapshots don't flap.
254
+
255
+ TYPE_WEIGHTS: dict[str, int] = {
256
+ 'VIDEO': 10,
257
+ 'COURSE': 8,
258
+ 'ARTICLE': 6,
259
+ 'DOCS': 4,
260
+ }
261
+
262
+ DIFFICULTY_ORDER: dict[str, int] = {
263
+ 'BEGINNER': 0,
264
+ 'INTERMEDIATE': 1,
265
+ 'ADVANCED': 2,
266
+ }
267
+
268
+ W_TYPE = 1.0
269
+ W_DIFF = 1.0
270
+ W_REL = 1.5
271
+ W_RATING = 0.5
272
+
273
+ DIFFICULTY_EXACT = 10.0
274
+ DIFFICULTY_ADJACENT = 5.0
275
+ DIFFICULTY_MISMATCH = 0.0
276
+
277
+ # A resource within this many levels of the user's current level is "near"
278
+ # (tier 0); anything further is "far" (tier 1) and is ranked below every near
279
+ # resource. With the 3-level scale this means: appropriate-level + one step are
280
+ # near, a two-step mismatch (e.g. ADVANCED for a BEGINNER) is far.
281
+ NEAR_MAX_DISTANCE = 1
282
+
283
+ DEFAULT_LIMIT_PER_SKILL = 3
284
+
285
+ # Upper bound on resources returned per skill gap. Caps an adversarial
286
+ # ?limit=99999999 so a single request can't try to materialise the whole
287
+ # catalog per skill; the curated catalog is small, so 50 is generous.
288
+ MAX_LIMIT_PER_SKILL = 50
289
+
290
+
291
+ @dataclass
292
+ class RecommendationItem:
293
+ resource_id: int
294
+ title: str
295
+ provider: str
296
+ url: str
297
+ type: str
298
+ difficulty_level: str
299
+ duration: int
300
+ rating: float
301
+ relevance_score: float
302
+ score: float
303
+
304
+ def to_dict(self) -> dict:
305
+ return asdict(self)
306
+
307
+
308
+ def _difficulty_match_score(anchor_level: str, resource_level: str) -> float:
309
+ anchor = DIFFICULTY_ORDER[anchor_level]
310
+ res = DIFFICULTY_ORDER[resource_level]
311
+ dist = abs(anchor - res)
312
+ if dist == 0:
313
+ return DIFFICULTY_EXACT
314
+ if dist == 1:
315
+ return DIFFICULTY_ADJACENT
316
+ return DIFFICULTY_MISMATCH
317
+
318
+
319
+ def _difficulty_tier(anchor_level: str, resource_level: str) -> int:
320
+ """Primary ranking key: 0 if the resource is within NEAR_MAX_DISTANCE levels
321
+ of the user's current level ("near"), else 1 ("far").
322
+
323
+ Sorting on this BEFORE the composite score guarantees a clearly-mismatched
324
+ resource (>=2 levels off, e.g. ADVANCED for a beginner) can never outrank an
325
+ appropriately-levelled one on type/relevance/rating alone.
326
+ """
327
+ dist = abs(DIFFICULTY_ORDER[anchor_level] - DIFFICULTY_ORDER[resource_level])
328
+ return 0 if dist <= NEAR_MAX_DISTANCE else 1
329
+
330
+
331
+ def _score(
332
+ type_: str,
333
+ anchor_level: str,
334
+ resource_level: str,
335
+ relevance_score: float,
336
+ rating: float,
337
+ ) -> float:
338
+ return (
339
+ TYPE_WEIGHTS.get(type_, 0) * W_TYPE
340
+ + _difficulty_match_score(anchor_level, resource_level) * W_DIFF
341
+ + (relevance_score * 10.0) * W_REL
342
+ + (rating / 5.0 * 10.0) * W_RATING
343
+ )
344
+
345
+
346
+ def compute_recommendations(
347
+ user,
348
+ role: Role,
349
+ limit_per_skill: int = DEFAULT_LIMIT_PER_SKILL,
350
+ ) -> dict:
351
+ """Return {"role_id", "role_name", "recommendations": {skill_id: [...]}}.
352
+
353
+ Only returns entries for skills with an actual gap (MISSING or INSUFFICIENT);
354
+ MET skills are skipped. Stable tie-break by resource_id ASC.
355
+ """
356
+ report = compute_gap_report(user, role)
357
+
358
+ gap_skills = [g for g in report.gaps if g.gap_type != 'MET']
359
+
360
+ recommendations: dict[int, list[dict]] = {}
361
+ if not gap_skills:
362
+ return {
363
+ 'role_id': role.id,
364
+ 'role_name': role.role_name,
365
+ 'recommendations': recommendations,
366
+ }
367
+
368
+ skill_ids = [g.skill_id for g in gap_skills]
369
+ links = (
370
+ SkillResource.objects
371
+ .filter(skill_id__in=skill_ids)
372
+ .select_related('resource')
373
+ )
374
+
375
+ by_skill: dict[int, list[tuple[SkillResource, Resource]]] = {
376
+ sid: [] for sid in skill_ids
377
+ }
378
+ for link in links:
379
+ by_skill[link.skill_id].append((link, link.resource))
380
+
381
+ # Anchor difficulty matching to the user's CURRENT level for each gap skill
382
+ # (derived from proficiency), not the role's required level — so learners are
383
+ # steered to material at their level first. See the scoring block above.
384
+ anchor_by_skill = {
385
+ g.skill_id: proficiency_to_level(g.user_proficiency) for g in gap_skills
386
+ }
387
+
388
+ for skill_id, pairs in by_skill.items():
389
+ anchor_level = anchor_by_skill[skill_id]
390
+ scored: list[tuple[int, float, int, RecommendationItem]] = []
391
+ for link, resource in pairs:
392
+ score = _score(
393
+ resource.type,
394
+ anchor_level,
395
+ resource.difficulty_level,
396
+ link.relevance_score,
397
+ resource.rating,
398
+ )
399
+ tier = _difficulty_tier(anchor_level, resource.difficulty_level)
400
+ scored.append((
401
+ tier,
402
+ score,
403
+ resource.id,
404
+ RecommendationItem(
405
+ resource_id=resource.id,
406
+ title=resource.title,
407
+ provider=resource.provider,
408
+ url=resource.url,
409
+ type=resource.type,
410
+ difficulty_level=resource.difficulty_level,
411
+ duration=resource.duration,
412
+ rating=resource.rating,
413
+ relevance_score=link.relevance_score,
414
+ score=round(score, 4),
415
+ ),
416
+ ))
417
+
418
+ # Primary: level tier (near before far). Secondary: composite score desc.
419
+ # Tertiary: resource_id asc for stable, snapshot-friendly ordering.
420
+ scored.sort(key=lambda t: (t[0], -t[1], t[2]))
421
+ top = [item.to_dict() for _, _, _, item in scored[:limit_per_skill]]
422
+ recommendations[skill_id] = top
423
+
424
+ return {
425
+ 'role_id': role.id,
426
+ 'role_name': role.role_name,
427
+ 'recommendations': recommendations,
428
+ }
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # Role suggestion service
433
+ # ---------------------------------------------------------------------------
434
+ #
435
+ # Ranks every active role by how well the user's current skills fit it, so the
436
+ # UI can surface "Recommended for you" (top fit) above the rest of the catalog.
437
+ # The fit reflects an uploaded resume automatically: CV → UserSkill → gap
438
+ # analysis is already wired, so suggestions update once parsed skills are saved.
439
+ #
440
+ # Reuses compute_gap_report per role (no duplicated readiness math). For the
441
+ # small curated catalog (~10 roles, ceiling ~50) the ~2 queries/role cost is
442
+ # negligible; batch into a single RoleSkill + UserSkill pass if it ever grows.
443
+
444
+ DEFAULT_TOP_MISSING = 3
445
+
446
+
447
+ def suggest_roles(user, top_missing: int = DEFAULT_TOP_MISSING) -> dict:
448
+ """Return {"has_skills", "roles": [...]} ranked by skill fit.
449
+
450
+ Each role row: role_id, role_name, industry, readiness, band,
451
+ matched_skills/total_skills, top_missing_skills (most-severe first),
452
+ no_requirements. Sorted by (no_requirements last, readiness desc,
453
+ role_name asc) — roles with no requirements report readiness=100
454
+ spuriously, so they sink to the bottom rather than top the list.
455
+ """
456
+ has_skills = UserSkill.objects.filter(user_id=user.id).exists()
457
+
458
+ rows: list[dict] = []
459
+ for role in Role.objects.filter(is_active=True):
460
+ report = compute_gap_report(user, role)
461
+ total = len(report.gaps)
462
+ matched = sum(1 for g in report.gaps if g.gap_type == 'MET')
463
+ # report.gaps is severity-sorted, so the first non-MET names are the
464
+ # most critical missing skills.
465
+ missing = [g.skill_name for g in report.gaps if g.gap_type != 'MET']
466
+ rows.append({
467
+ 'role_id': role.id,
468
+ 'role_name': role.role_name,
469
+ 'industry': role.industry,
470
+ 'readiness': round(report.readiness, 2),
471
+ 'band': report.band,
472
+ 'matched_skills': matched,
473
+ 'total_skills': total,
474
+ 'top_missing_skills': missing[:top_missing],
475
+ 'no_requirements': report.no_requirements,
476
+ })
477
+
478
+ rows.sort(key=lambda r: (r['no_requirements'], -r['readiness'], r['role_name']))
479
+ return {'has_skills': has_skills, 'roles': rows}
apps/analysis/tests/__init__.py ADDED
File without changes
apps/analysis/tests/test_gap_analysis.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from django.contrib.auth import get_user_model
3
+ from rest_framework import status
4
+ from rest_framework.test import APIClient
5
+
6
+ from apps.analysis.services import (
7
+ LEVEL_THRESHOLDS,
8
+ MANDATORY_CAP,
9
+ compute_gap_report,
10
+ )
11
+ from apps.roles.models import Role, RoleSkill, UserTargetRole
12
+ from apps.skills.models import Skill, UserSkill
13
+
14
+ User = get_user_model()
15
+ pytestmark = pytest.mark.django_db
16
+
17
+ ANALYSIS_URL = '/api/analysis/'
18
+
19
+
20
+ @pytest.fixture
21
+ def user():
22
+ return User.objects.create_user(
23
+ username='analysis@example.com',
24
+ email='analysis@example.com',
25
+ password='password123',
26
+ name='Analysis Tester',
27
+ )
28
+
29
+
30
+ @pytest.fixture
31
+ def auth_client(user):
32
+ client = APIClient()
33
+ client.force_authenticate(user=user)
34
+ return client
35
+
36
+
37
+ def _skill(name, category='Programming'):
38
+ return Skill.objects.create(skill_name=name, category=category, difficulty_level='BEGINNER')
39
+
40
+
41
+ def _role_with(skill_specs):
42
+ """skill_specs = [(skill_name, required_level, weight, is_mandatory), ...]"""
43
+ role = Role.objects.create(role_name=f'Role-{id(skill_specs)}', industry='Tech', is_active=True)
44
+ for name, level, weight, mandatory in skill_specs:
45
+ RoleSkill.objects.create(
46
+ role=role,
47
+ skill=_skill(name),
48
+ required_level=level,
49
+ weight=weight,
50
+ is_mandatory=mandatory,
51
+ )
52
+ return role
53
+
54
+
55
+ def _set_prof(user, skill_name, proficiency, level='BEGINNER'):
56
+ skill = Skill.objects.get(skill_name=skill_name)
57
+ UserSkill.objects.update_or_create(
58
+ user=user, skill=skill,
59
+ defaults={'proficiency': proficiency, 'user_level': level},
60
+ )
61
+
62
+
63
+ class TestReportToDict:
64
+ def test_to_dict_exposes_mandatory_cap_value(self, user):
65
+ # F18: the numeric cap value is exposed so the UI need not hardcode "60".
66
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, False)])
67
+ _set_prof(user, 'Python', 60)
68
+ d = compute_gap_report(user, role).to_dict()
69
+ assert d['mandatory_cap'] == MANDATORY_CAP
70
+
71
+
72
+ class TestClassification:
73
+ def test_met_when_proficiency_equals_threshold(self, user):
74
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
75
+ _set_prof(user, 'Python', 60)
76
+ report = compute_gap_report(user, role)
77
+ assert report.gaps[0].gap_type == 'MET'
78
+ assert report.gaps[0].severity is None
79
+ assert report.gaps[0].gap == 0
80
+ assert report.gaps[0].satisfaction == 1.0
81
+
82
+ def test_missing_mandatory_is_critical(self, user):
83
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
84
+ report = compute_gap_report(user, role)
85
+ g = report.gaps[0]
86
+ assert g.gap_type == 'MISSING'
87
+ assert g.severity == 'CRITICAL'
88
+ assert g.user_proficiency == 0
89
+
90
+ def test_missing_optional_is_medium(self, user):
91
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, False)])
92
+ report = compute_gap_report(user, role)
93
+ assert report.gaps[0].gap_type == 'MISSING'
94
+ assert report.gaps[0].severity == 'MEDIUM'
95
+
96
+ def test_insufficient_mandatory_forces_high(self, user):
97
+ # Ratio 0.17 (low in absolute terms), but mandatory promotes to HIGH.
98
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
99
+ _set_prof(user, 'Python', 50)
100
+ g = compute_gap_report(user, role).gaps[0]
101
+ assert g.gap_type == 'INSUFFICIENT'
102
+ assert g.severity == 'HIGH'
103
+
104
+ def test_insufficient_ratio_high(self, user):
105
+ # U=20, T=60 → ratio = 40/60 ≈ 0.67 ≥ 0.5 → HIGH
106
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, False)])
107
+ _set_prof(user, 'Python', 20)
108
+ assert compute_gap_report(user, role).gaps[0].severity == 'HIGH'
109
+
110
+ def test_insufficient_ratio_medium(self, user):
111
+ # U=60, T=100 → ratio = 40/100 = 0.4 → MEDIUM
112
+ role = _role_with([('Python', 'ADVANCED', 1.0, False)])
113
+ _set_prof(user, 'Python', 60)
114
+ assert compute_gap_report(user, role).gaps[0].severity == 'MEDIUM'
115
+
116
+ def test_insufficient_ratio_low(self, user):
117
+ # U=85, T=100 → ratio = 0.15 < 0.25 → LOW
118
+ role = _role_with([('Python', 'ADVANCED', 1.0, False)])
119
+ _set_prof(user, 'Python', 85)
120
+ assert compute_gap_report(user, role).gaps[0].severity == 'LOW'
121
+
122
+ def test_thresholds_match_spec(self):
123
+ assert LEVEL_THRESHOLDS == {'BEGINNER': 40, 'INTERMEDIATE': 60, 'ADVANCED': 100}
124
+
125
+
126
+ class TestReadiness:
127
+ def test_all_met_yields_100(self, user):
128
+ role = _role_with([
129
+ ('Python', 'INTERMEDIATE', 1.0, True),
130
+ ('SQL', 'BEGINNER', 1.0, False),
131
+ ])
132
+ _set_prof(user, 'Python', 60)
133
+ _set_prof(user, 'SQL', 40)
134
+ report = compute_gap_report(user, role)
135
+ assert report.readiness == 100.0
136
+ assert report.band == 'STRONG'
137
+ assert report.mandatory_cap_applied is False
138
+
139
+ def test_weighted_aggregation(self, user):
140
+ # Python weight 3, fully met (S=1). SQL weight 1, unmet (U=0, S=0).
141
+ # Raw = (1*3 + 0*1)/(3+1) * 100 = 75. SQL is optional, no cap.
142
+ role = _role_with([
143
+ ('Python', 'INTERMEDIATE', 3.0, False),
144
+ ('SQL', 'INTERMEDIATE', 1.0, False),
145
+ ])
146
+ _set_prof(user, 'Python', 60)
147
+ report = compute_gap_report(user, role)
148
+ assert report.readiness == 75.0
149
+ assert report.band == 'GOOD'
150
+
151
+ def test_mandatory_cap_applies(self, user):
152
+ # Three skills, two met heavily weighted, one mandatory at 0 → raw would
153
+ # be high but cap pins readiness at 60.
154
+ role = _role_with([
155
+ ('Python', 'INTERMEDIATE', 5.0, False),
156
+ ('SQL', 'INTERMEDIATE', 5.0, False),
157
+ ('Docker', 'INTERMEDIATE', 1.0, True), # mandatory, unmet
158
+ ])
159
+ _set_prof(user, 'Python', 60)
160
+ _set_prof(user, 'SQL', 60)
161
+ report = compute_gap_report(user, role)
162
+ assert report.mandatory_cap_applied is True
163
+ assert report.readiness == MANDATORY_CAP
164
+ assert report.band == 'PARTIAL'
165
+
166
+ def test_mandatory_cap_not_applied_when_all_mandatory_met(self, user):
167
+ role = _role_with([
168
+ ('Python', 'INTERMEDIATE', 1.0, True),
169
+ ('Docker', 'BEGINNER', 1.0, False), # optional, unmet
170
+ ])
171
+ _set_prof(user, 'Python', 60)
172
+ report = compute_gap_report(user, role)
173
+ assert report.mandatory_cap_applied is False
174
+ # Raw = (1.0 + 0)/2 * 100 = 50 → PARTIAL (no cap needed)
175
+ assert report.readiness == 50.0
176
+ assert report.band == 'PARTIAL'
177
+
178
+ def test_cap_not_applied_when_raw_below_cap(self, user):
179
+ # Mandatory unmet AND raw already below 60 — cap flag stays False.
180
+ role = _role_with([
181
+ ('Python', 'INTERMEDIATE', 1.0, True),
182
+ ('SQL', 'INTERMEDIATE', 1.0, True),
183
+ ])
184
+ _set_prof(user, 'Python', 30)
185
+ # SQL unmet → 0 sat
186
+ report = compute_gap_report(user, role)
187
+ # Raw = (30/60 + 0)/2 * 100 = 25 → WEAK
188
+ assert report.readiness == 25.0
189
+ assert report.mandatory_cap_applied is False
190
+ assert report.band == 'WEAK'
191
+
192
+ def test_no_requirements_returns_100(self, user):
193
+ role = Role.objects.create(role_name='Empty', industry='Tech', is_active=True)
194
+ report = compute_gap_report(user, role)
195
+ assert report.readiness == 100.0
196
+ assert report.no_requirements is True
197
+ assert report.gaps == []
198
+
199
+ def test_zero_weight_sum_returns_100(self, user):
200
+ role = _role_with([
201
+ ('Python', 'INTERMEDIATE', 0.0, False),
202
+ ('SQL', 'BEGINNER', 0.0, False),
203
+ ])
204
+ report = compute_gap_report(user, role)
205
+ assert report.readiness == 100.0
206
+ assert report.no_requirements is True
207
+ assert len(report.gaps) == 2
208
+
209
+
210
+ class TestBands:
211
+ # With a single ADVANCED non-mandatory skill (T=100, W=1), the readiness
212
+ # score equals the proficiency exactly — so proficiency is the knob that
213
+ # lands us on each side of every band boundary.
214
+ @pytest.mark.parametrize('prof, expected_readiness, expected_band', [
215
+ (100, 100.0, 'STRONG'),
216
+ (85, 85.0, 'STRONG'), # lower edge of STRONG (≥85)
217
+ (84, 84.0, 'GOOD'), # just below STRONG
218
+ (65, 65.0, 'GOOD'), # lower edge of GOOD (≥65)
219
+ (64, 64.0, 'PARTIAL'), # just below GOOD
220
+ (45, 45.0, 'PARTIAL'), # lower edge of PARTIAL (≥45)
221
+ (44, 44.0, 'WEAK'), # just below PARTIAL
222
+ (0, 0.0, 'WEAK'),
223
+ ])
224
+ def test_band_boundaries(self, user, prof, expected_readiness, expected_band):
225
+ role = _role_with([('Python', 'ADVANCED', 1.0, False)])
226
+ _set_prof(user, 'Python', prof)
227
+ report = compute_gap_report(user, role)
228
+ assert report.readiness == pytest.approx(expected_readiness, abs=0.01)
229
+ assert report.band == expected_band
230
+
231
+
232
+ class TestGapSorting:
233
+ def test_gaps_sorted_by_severity_then_weight(self, user):
234
+ role = _role_with([
235
+ ('Python', 'INTERMEDIATE', 1.0, True), # MISSING CRITICAL
236
+ ('SQL', 'INTERMEDIATE', 5.0, False), # MET
237
+ ('Docker', 'INTERMEDIATE', 2.0, False), # MISSING MEDIUM
238
+ ])
239
+ _set_prof(user, 'SQL', 60)
240
+ report = compute_gap_report(user, role)
241
+ names = [g.skill_name for g in report.gaps]
242
+ # CRITICAL > MEDIUM > MET
243
+ assert names == ['Python', 'Docker', 'SQL']
244
+
245
+
246
+ class TestAPI:
247
+ def test_requires_auth(self):
248
+ response = APIClient().get(ANALYSIS_URL)
249
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
250
+
251
+ def test_400_when_no_target_role(self, auth_client):
252
+ response = auth_client.get(ANALYSIS_URL)
253
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
254
+
255
+ def test_200_with_active_target_role(self, auth_client, user):
256
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
257
+ UserTargetRole.objects.create(user=user, role=role, is_active=True)
258
+ _set_prof(user, 'Python', 60)
259
+ response = auth_client.get(ANALYSIS_URL)
260
+ assert response.status_code == status.HTTP_200_OK
261
+ assert response.data['role_id'] == role.id
262
+ assert response.data['readiness'] == 100.0
263
+ assert response.data['band'] == 'STRONG'
264
+ assert len(response.data['gaps']) == 1
265
+ assert response.data['gaps'][0]['gap_type'] == 'MET'
266
+
267
+ def test_role_id_override(self, auth_client, user):
268
+ role = _role_with([('Python', 'BEGINNER', 1.0, False)])
269
+ response = auth_client.get(f'{ANALYSIS_URL}?role_id={role.id}')
270
+ assert response.status_code == status.HTTP_200_OK
271
+ assert response.data['role_id'] == role.id
272
+
273
+ def test_role_id_not_found(self, auth_client):
274
+ response = auth_client.get(f'{ANALYSIS_URL}?role_id=99999')
275
+ assert response.status_code == status.HTTP_404_NOT_FOUND
276
+
277
+ def test_inactive_role_id_rejected(self, auth_client):
278
+ role = Role.objects.create(role_name='Old', industry='X', is_active=False)
279
+ response = auth_client.get(f'{ANALYSIS_URL}?role_id={role.id}')
280
+ assert response.status_code == status.HTTP_404_NOT_FOUND
apps/analysis/tests/test_recommendations.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the recommendation ranking service + endpoint.
2
+
3
+ See research/08-next-modules-build-plan.md §Module A. Logic under test lives
4
+ in apps.analysis.services.compute_recommendations.
5
+ """
6
+ import pytest
7
+ from django.contrib.auth import get_user_model
8
+ from rest_framework.test import APIClient
9
+
10
+ from apps.analysis.services import compute_recommendations
11
+ from apps.resources.models import Resource, SkillResource
12
+ from apps.roles.models import Role, RoleSkill, UserTargetRole
13
+ from apps.skills.models import Skill, UserSkill
14
+
15
+ User = get_user_model()
16
+ pytestmark = pytest.mark.django_db
17
+
18
+ RECS_URL = '/api/recommendations/'
19
+
20
+
21
+ @pytest.fixture
22
+ def user():
23
+ return User.objects.create_user(
24
+ username='recs@example.com',
25
+ email='recs@example.com',
26
+ password='password123',
27
+ name='Recs Tester',
28
+ )
29
+
30
+
31
+ @pytest.fixture
32
+ def auth_client(user):
33
+ client = APIClient()
34
+ client.force_authenticate(user=user)
35
+ return client
36
+
37
+
38
+ def _skill(name, category='Programming'):
39
+ return Skill.objects.create(
40
+ skill_name=name, category=category, difficulty_level='BEGINNER',
41
+ )
42
+
43
+
44
+ def _role_with(skill_specs, name_hint='Role'):
45
+ role = Role.objects.create(
46
+ role_name=f'{name_hint}-{id(skill_specs)}',
47
+ industry='Tech', is_active=True,
48
+ )
49
+ for name, level, weight, mandatory in skill_specs:
50
+ RoleSkill.objects.create(
51
+ role=role, skill=_skill(name),
52
+ required_level=level, weight=weight, is_mandatory=mandatory,
53
+ )
54
+ return role
55
+
56
+
57
+ def _set_prof(user, skill_name, proficiency, level='BEGINNER'):
58
+ skill = Skill.objects.get(skill_name=skill_name)
59
+ UserSkill.objects.update_or_create(
60
+ user=user, skill=skill,
61
+ defaults={'proficiency': proficiency, 'user_level': level},
62
+ )
63
+
64
+
65
+ def _resource(title, r_type, difficulty='INTERMEDIATE', rating=3.5, url=None,
66
+ duration=60):
67
+ return Resource.objects.create(
68
+ title=title,
69
+ provider='TestProvider',
70
+ url=url or f'https://example.com/{title.replace(" ", "-").lower()}',
71
+ difficulty_level=difficulty,
72
+ duration=duration,
73
+ type=r_type,
74
+ rating=rating,
75
+ )
76
+
77
+
78
+ def _link(skill_name, resource, relevance=1.0):
79
+ SkillResource.objects.create(
80
+ skill=Skill.objects.get(skill_name=skill_name),
81
+ resource=resource, relevance_score=relevance,
82
+ )
83
+
84
+
85
+ class TestRanking:
86
+ def test_type_preference_weight_order(self, user):
87
+ """Equal rating/relevance/difficulty → VIDEO > COURSE > ARTICLE > DOCS."""
88
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
89
+ for ttype in ('VIDEO', 'COURSE', 'ARTICLE', 'DOCS'):
90
+ _link('Python', _resource(
91
+ f'{ttype} res', ttype,
92
+ difficulty='INTERMEDIATE', rating=3.5,
93
+ ), relevance=0.8)
94
+
95
+ skill_id = Skill.objects.get(skill_name='Python').id
96
+ rec = compute_recommendations(user, role, limit_per_skill=10)
97
+ order = [r['type'] for r in rec['recommendations'][skill_id]]
98
+ assert order == ['VIDEO', 'COURSE', 'ARTICLE', 'DOCS']
99
+
100
+ def test_difficulty_matches_user_level_beginner(self, user):
101
+ """Difficulty is anchored to the USER's current level, not the role's
102
+ required level. A junior (proficiency 30 → BEGINNER) on an
103
+ INTERMEDIATE-required skill gets BEGINNER material first, ADVANCED last."""
104
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
105
+ _set_prof(user, 'Python', 30) # BEGINNER level, still < 60 → INSUFFICIENT
106
+ for diff in ('BEGINNER', 'INTERMEDIATE', 'ADVANCED'):
107
+ _link('Python', _resource(
108
+ f'{diff} course', 'COURSE', difficulty=diff, rating=3.5,
109
+ ), relevance=0.8)
110
+
111
+ skill_id = Skill.objects.get(skill_name='Python').id
112
+ rec = compute_recommendations(user, role, limit_per_skill=10)
113
+ order = [r['difficulty_level'] for r in rec['recommendations'][skill_id]]
114
+ assert order[0] == 'BEGINNER'
115
+ assert order[-1] == 'ADVANCED'
116
+
117
+ def test_difficulty_anchor_follows_higher_user_level(self, user):
118
+ """The anchor moves with the user: an INTERMEDIATE learner (proficiency
119
+ 55) on an ADVANCED-required skill gets INTERMEDIATE material first —
120
+ proving the match follows the user, not the role's required level."""
121
+ role = _role_with([('Python', 'ADVANCED', 1.0, True)])
122
+ _set_prof(user, 'Python', 55) # INTERMEDIATE level, still < 100 → INSUFFICIENT
123
+ for diff in ('BEGINNER', 'INTERMEDIATE', 'ADVANCED'):
124
+ _link('Python', _resource(
125
+ f'{diff} course', 'COURSE', difficulty=diff, rating=3.5,
126
+ ), relevance=0.8)
127
+
128
+ skill_id = Skill.objects.get(skill_name='Python').id
129
+ rec = compute_recommendations(user, role, limit_per_skill=10)
130
+ first = rec['recommendations'][skill_id][0]
131
+ assert first['difficulty_level'] == 'INTERMEDIATE'
132
+
133
+ def test_relevance_score_tiebreaker(self, user):
134
+ """Two COURSE rows same rating/difficulty → higher relevance wins."""
135
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
136
+ low = _resource('low-rel', 'COURSE', rating=3.5)
137
+ high = _resource('high-rel', 'COURSE', rating=3.5)
138
+ _link('Python', low, relevance=0.3)
139
+ _link('Python', high, relevance=0.9)
140
+
141
+ skill_id = Skill.objects.get(skill_name='Python').id
142
+ rec = compute_recommendations(user, role, limit_per_skill=10)
143
+ titles = [r['title'] for r in rec['recommendations'][skill_id]]
144
+ assert titles[0] == 'high-rel'
145
+
146
+ def test_rating_tiebreaker(self, user):
147
+ """Two COURSE rows same relevance → higher rating wins."""
148
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
149
+ low = _resource('low-rating', 'COURSE', rating=2.0)
150
+ high = _resource('high-rating', 'COURSE', rating=5.0)
151
+ _link('Python', low, relevance=0.8)
152
+ _link('Python', high, relevance=0.8)
153
+
154
+ skill_id = Skill.objects.get(skill_name='Python').id
155
+ rec = compute_recommendations(user, role, limit_per_skill=10)
156
+ titles = [r['title'] for r in rec['recommendations'][skill_id]]
157
+ assert titles[0] == 'high-rating'
158
+
159
+ def test_deterministic_tie_break_by_id(self, user):
160
+ """Two identical rows → lower resource_id comes first so snapshots are stable."""
161
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
162
+ first = _resource('first', 'COURSE', rating=3.5, url='https://e.com/a')
163
+ second = _resource('second', 'COURSE', rating=3.5, url='https://e.com/b')
164
+ _link('Python', first, relevance=0.8)
165
+ _link('Python', second, relevance=0.8)
166
+ assert first.id < second.id
167
+
168
+ skill_id = Skill.objects.get(skill_name='Python').id
169
+ rec = compute_recommendations(user, role, limit_per_skill=10)
170
+ ids = [r['resource_id'] for r in rec['recommendations'][skill_id]]
171
+ assert ids == [first.id, second.id]
172
+
173
+ def test_relevance_can_override_type_preference(self, user):
174
+ """Very-relevant DOCS (1.0) should beat low-relevance VIDEO (0.3) —
175
+ this is the documented behavior from the ranking design."""
176
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
177
+ video = _resource('sparse video', 'VIDEO', rating=3.5)
178
+ docs = _resource('perfect docs', 'DOCS', rating=3.5)
179
+ _link('Python', video, relevance=0.3)
180
+ _link('Python', docs, relevance=1.0)
181
+
182
+ skill_id = Skill.objects.get(skill_name='Python').id
183
+ rec = compute_recommendations(user, role, limit_per_skill=10)
184
+ titles = [r['title'] for r in rec['recommendations'][skill_id]]
185
+ assert titles[0] == 'perfect docs'
186
+
187
+ def test_far_level_resource_never_outranks_nearer_one(self, user):
188
+ """Level-tier guarantee: a resource 2 levels off the user's current
189
+ level ("far") can never outrank one within 1 level ("near"), even when
190
+ the far resource wins on type, relevance AND rating.
191
+
192
+ Regression for the seeded-SQL case where an ADVANCED course (higher
193
+ type/relevance) edged an INTERMEDIATE doc for a BEGINNER learner."""
194
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
195
+ _set_prof(user, 'Python', 30) # BEGINNER anchor
196
+ # near (1 level off): deliberately the WEAKER resource on every other axis
197
+ near = _resource('intermediate docs', 'DOCS',
198
+ difficulty='INTERMEDIATE', rating=4.0)
199
+ # far (2 levels off): stronger type + relevance + rating
200
+ far = _resource('advanced course', 'COURSE',
201
+ difficulty='ADVANCED', rating=5.0)
202
+ _link('Python', near, relevance=0.7)
203
+ _link('Python', far, relevance=1.0)
204
+
205
+ skill_id = Skill.objects.get(skill_name='Python').id
206
+ rec = compute_recommendations(user, role, limit_per_skill=10)
207
+ order = [r['difficulty_level'] for r in rec['recommendations'][skill_id]]
208
+ assert order == ['INTERMEDIATE', 'ADVANCED']
209
+
210
+
211
+ class TestFilteringAndShape:
212
+ def test_met_skills_excluded(self, user):
213
+ role = _role_with([
214
+ ('Python', 'INTERMEDIATE', 1.0, True), # we'll MET this
215
+ ('SQL', 'INTERMEDIATE', 1.0, True), # gap
216
+ ])
217
+ _set_prof(user, 'Python', 60) # == threshold → MET
218
+ _link('Python', _resource('py docs', 'DOCS'), relevance=1.0)
219
+ _link('SQL', _resource('sql docs', 'DOCS'), relevance=1.0)
220
+
221
+ python_id = Skill.objects.get(skill_name='Python').id
222
+ sql_id = Skill.objects.get(skill_name='SQL').id
223
+ rec = compute_recommendations(user, role)
224
+ assert python_id not in rec['recommendations']
225
+ assert sql_id in rec['recommendations']
226
+
227
+ def test_top_n_per_skill(self, user):
228
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
229
+ for i in range(10):
230
+ _link('Python', _resource(
231
+ f'r{i}', 'COURSE', url=f'https://e.com/{i}',
232
+ ), relevance=0.8)
233
+
234
+ skill_id = Skill.objects.get(skill_name='Python').id
235
+ rec = compute_recommendations(user, role, limit_per_skill=3)
236
+ assert len(rec['recommendations'][skill_id]) == 3
237
+
238
+ def test_empty_catalog_returns_empty_per_gap(self, user):
239
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
240
+ skill_id = Skill.objects.get(skill_name='Python').id
241
+ rec = compute_recommendations(user, role)
242
+ assert rec['recommendations'][skill_id] == []
243
+
244
+ def test_no_gaps_returns_empty_map(self, user):
245
+ """User with zero gaps (or role with no requirements) gets an empty
246
+ recommendations map, not an error."""
247
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
248
+ _set_prof(user, 'Python', 100)
249
+ rec = compute_recommendations(user, role)
250
+ assert rec['recommendations'] == {}
251
+
252
+
253
+ class TestEndpoint:
254
+ def test_endpoint_uses_active_target_role(self, user, auth_client):
255
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
256
+ UserTargetRole.objects.create(user=user, role=role, is_active=True)
257
+ _link('Python', _resource('py course', 'COURSE'), relevance=0.8)
258
+
259
+ r = auth_client.get(RECS_URL)
260
+ assert r.status_code == 200, r.data
261
+ assert r.data['role_id'] == role.id
262
+ skill_id = Skill.objects.get(skill_name='Python').id
263
+ assert skill_id in [int(k) for k in r.data['recommendations']]
264
+
265
+ def test_endpoint_no_target_no_param_returns_400(self, auth_client):
266
+ r = auth_client.get(RECS_URL)
267
+ assert r.status_code == 400
268
+ assert 'target role' in r.data['detail'].lower()
269
+
270
+ def test_endpoint_role_param_overrides_target(self, user, auth_client):
271
+ a = _role_with([('Python', 'INTERMEDIATE', 1.0, True)], name_hint='A')
272
+ b = _role_with([('SQL', 'INTERMEDIATE', 1.0, True)], name_hint='B')
273
+ UserTargetRole.objects.create(user=user, role=a, is_active=True)
274
+ _link('Python', _resource('py c', 'COURSE'), relevance=0.8)
275
+ _link('SQL', _resource('sql c', 'COURSE'), relevance=0.8)
276
+
277
+ r = auth_client.get(f'{RECS_URL}?role={b.id}')
278
+ assert r.status_code == 200
279
+ assert r.data['role_id'] == b.id
280
+
281
+ def test_endpoint_limit_query_param(self, user, auth_client):
282
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
283
+ UserTargetRole.objects.create(user=user, role=role, is_active=True)
284
+ for i in range(5):
285
+ _link('Python', _resource(
286
+ f'r{i}', 'COURSE', url=f'https://e.com/{i}',
287
+ ), relevance=0.8)
288
+
289
+ r = auth_client.get(f'{RECS_URL}?limit=2')
290
+ assert r.status_code == 200
291
+ skill_id = Skill.objects.get(skill_name='Python').id
292
+ assert len(r.data['recommendations'][skill_id]) == 2
293
+
294
+ def test_endpoint_rejects_invalid_limit(self, user, auth_client):
295
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
296
+ UserTargetRole.objects.create(user=user, role=role, is_active=True)
297
+
298
+ r = auth_client.get(f'{RECS_URL}?limit=0')
299
+ assert r.status_code == 400
300
+
301
+ r = auth_client.get(f'{RECS_URL}?limit=abc')
302
+ assert r.status_code == 400
303
+
304
+ def test_endpoint_empty_for_fully_met_user(self, user, auth_client):
305
+ role = _role_with([('Python', 'BEGINNER', 1.0, True)])
306
+ UserTargetRole.objects.create(user=user, role=role, is_active=True)
307
+ _set_prof(user, 'Python', 100)
308
+
309
+ r = auth_client.get(RECS_URL)
310
+ assert r.status_code == 200
311
+ assert r.data['recommendations'] == {}
312
+
313
+ def test_endpoint_clamps_huge_limit(self, user, auth_client):
314
+ """An adversarial ?limit=100000 is clamped to MAX_LIMIT_PER_SKILL, not
315
+ passed through verbatim. With more links than the cap, the per-skill
316
+ list never exceeds MAX_LIMIT_PER_SKILL."""
317
+ from apps.analysis.services import MAX_LIMIT_PER_SKILL
318
+
319
+ role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)])
320
+ UserTargetRole.objects.create(user=user, role=role, is_active=True)
321
+ for i in range(MAX_LIMIT_PER_SKILL + 10):
322
+ _link('Python', _resource(
323
+ f'r{i}', 'COURSE', url=f'https://e.com/{i}',
324
+ ), relevance=0.8)
325
+
326
+ r = auth_client.get(f'{RECS_URL}?limit=100000')
327
+ assert r.status_code == 200, r.data
328
+ skill_id = Skill.objects.get(skill_name='Python').id
329
+ assert len(r.data['recommendations'][skill_id]) == MAX_LIMIT_PER_SKILL
330
+
331
+ def test_endpoint_requires_authentication(self):
332
+ client = APIClient()
333
+ r = client.get(RECS_URL)
334
+ assert r.status_code == 401