Spaces:
Runtime error
Runtime error
Deploy GapGuide backend (Docker)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +11 -0
- .env.example +33 -0
- .gitattributes +1 -35
- .ruff_cache/.gitignore +2 -0
- .ruff_cache/0.15.9/14848081398162790867 +0 -0
- .ruff_cache/0.15.9/397764829197027149 +0 -0
- .ruff_cache/0.15.9/4097662610744648579 +0 -0
- .ruff_cache/0.15.9/5949281871524638798 +0 -0
- .ruff_cache/0.15.9/6974492653149135859 +0 -0
- .ruff_cache/CACHEDIR.TAG +1 -0
- Dockerfile +43 -0
- INSTALL.md +118 -0
- README.md +13 -5
- apps/__init__.py +0 -0
- apps/accounts/__init__.py +0 -0
- apps/accounts/admin.py +26 -0
- apps/accounts/apps.py +6 -0
- apps/accounts/management/__init__.py +0 -0
- apps/accounts/management/commands/__init__.py +0 -0
- apps/accounts/management/commands/seed_demo_users.py +290 -0
- apps/accounts/migrations/0001_initial.py +46 -0
- apps/accounts/migrations/0002_user_created_at_alter_user_role_type.py +25 -0
- apps/accounts/migrations/0003_userprofile.py +28 -0
- apps/accounts/migrations/__init__.py +0 -0
- apps/accounts/models.py +41 -0
- apps/accounts/ner/__init__.py +57 -0
- apps/accounts/ner/jobbert.py +117 -0
- apps/accounts/ner/lexical.py +42 -0
- apps/accounts/ner/nucha.py +76 -0
- apps/accounts/ner/sbert.py +131 -0
- apps/accounts/ner/skillner.py +95 -0
- apps/accounts/resume_parser.py +456 -0
- apps/accounts/serializers.py +112 -0
- apps/accounts/tests/__init__.py +0 -0
- apps/accounts/tests/test_auth.py +140 -0
- apps/accounts/tests/test_ner_dispatcher.py +196 -0
- apps/accounts/tests/test_ner_layers.py +221 -0
- apps/accounts/tests/test_profile.py +152 -0
- apps/accounts/tests/test_resume_parser.py +254 -0
- apps/accounts/tests/test_resume_parser_integration.py +102 -0
- apps/accounts/urls.py +16 -0
- apps/accounts/views.py +293 -0
- apps/analysis/__init__.py +0 -0
- apps/analysis/admin.py +1 -0
- apps/analysis/apps.py +6 -0
- apps/analysis/models.py +1 -0
- apps/analysis/services.py +479 -0
- apps/analysis/tests/__init__.py +0 -0
- apps/analysis/tests/test_gap_analysis.py +280 -0
- 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 |
-
*.
|
| 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|