"""End-to-end NER chain integration test. Gated on ``GAPGUIDE_ML_SMOKE=1`` — runs the REAL four-layer chain (Nucha + JobBERT + SkillNER + SBERT + lexical) against a fixture PDF. CI sets GAPGUIDE_PARSE_LAYERS=lexical (see conftest.py), so this opt-in test is the only place we verify the ML stack actually works end-to-end. Run manually: $env:GAPGUIDE_ML_SMOKE="1" pytest apps/accounts/tests/test_resume_parser_integration.py -q First run downloads ~1 GB of HF models + requires `en_core_web_lg` installed and pgvector extension in Postgres with SkillEmbedding populated. """ from __future__ import annotations import os from pathlib import Path import pytest pytestmark = [ pytest.mark.django_db(transaction=True), pytest.mark.skipif( os.environ.get("GAPGUIDE_ML_SMOKE") != "1", reason="ML smoke disabled (set GAPGUIDE_ML_SMOKE=1 to enable)", ), ] FIX = Path(__file__).resolve().parent.parent.parent.parent / 'tests' / 'fixtures' / 'resumes' @pytest.fixture def seeded_catalog(): """Real catalog + SBERT embeddings. Requires: * pgvector extension on the test DB * en_core_web_lg installed * seed_initial_skills has been run (we do it here) """ from django.core.management import call_command call_command('seed_initial_skills') # Build embeddings for the seeded skills. from apps.skills.models import Skill, SkillEmbedding from sentence_transformers import SentenceTransformer model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2') skills = list(Skill.objects.all()) vectors = model.encode( [f"{s.skill_name} — {s.description or s.category}" for s in skills], normalize_embeddings=True, ) for skill, vec in zip(skills, vectors): SkillEmbedding.objects.update_or_create( skill=skill, defaults={ 'embedding': vec.tolist(), 'source_text': skill.skill_name, }, ) return skills def test_full_chain_extracts_core_skills_from_strong_fixture(seeded_catalog): """resume_ds_strong.pdf must yield at least {Python, SQL} via the full chain. We deliberately don't assert exact counts / confidences — those depend on the specific HF model versions and would flake across upgrades. """ # Force the full chain. os.environ.pop('GAPGUIDE_PARSE_LAYERS', None) from apps.accounts.resume_parser import parse_resume_envelope pdf = (FIX / 'resume_ds_strong.pdf').read_bytes() env = parse_resume_envelope(pdf) names = {s['skill_name'] for s in env['skills']} assert {'Python', 'SQL'}.issubset(names), (env['parser_version'], names) # At least one BERT/SBERT/SkillNER layer must have fired — proves the # chain isn't silently falling back to lexical-only. assert any( layer in env['parser_version'] for layer in ('nucha', 'jobbert', 'skillner', 'sbert') ), env['parser_version'] def test_minimal_fixture_exercises_sbert_fallback(seeded_catalog): """resume_minimal.pdf uses paraphrased skills ("data visualisation", "version control"). Lexical layer alone returns nothing; the SBERT fallback should resolve at least one paraphrase to a catalog skill. """ os.environ.pop('GAPGUIDE_PARSE_LAYERS', None) from apps.accounts.resume_parser import parse_resume_envelope pdf = (FIX / 'resume_minimal.pdf').read_bytes() env = parse_resume_envelope(pdf) # Either SBERT or SkillNER should salvage at least one catalog hit here — # otherwise the fallback chain isn't doing the job it was designed for. assert len(env['skills']) >= 1, (env['parser_version'], env['skills'])