Spaces:
Runtime error
Runtime error
| """Per-layer unit tests for Module 8 NER layers. | |
| Each layer's heavy model is mocked so CI doesn't pull ~1 GB of BERT weights. | |
| Runtime integration with real models is covered by | |
| test_resume_parser_integration.py (gated on GAPGUIDE_ML_SMOKE=1). | |
| """ | |
| from __future__ import annotations | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| # --------------------------------------------------------------------------- | |
| # Nucha layer | |
| # --------------------------------------------------------------------------- | |
| class TestNuchaLayer: | |
| def test_predict_extracts_hard_and_soft_skills(self): | |
| """Pipeline returns HSKILL/SSKILL spans β alias dict keyed on span.""" | |
| from apps.accounts.ner import nucha | |
| fake_pipeline = MagicMock(return_value=[ | |
| {'entity_group': 'HSKILL', 'score': 0.93, 'word': 'Python', | |
| 'start': 0, 'end': 6}, | |
| {'entity_group': 'HSKILL', 'score': 0.88, 'word': 'SQL', | |
| 'start': 10, 'end': 13}, | |
| {'entity_group': 'SSKILL', 'score': 0.71, 'word': 'teamwork', | |
| 'start': 20, 'end': 28}, | |
| # Non-skill tokens must be dropped. | |
| {'entity_group': 'O', 'score': 0.99, 'word': 'and', | |
| 'start': 14, 'end': 17}, | |
| ]) | |
| with patch.object(nucha, '_pipeline', fake_pipeline): | |
| out = nucha.layer.predict("Python SQL and teamwork") | |
| assert out == { | |
| 'Python': pytest.approx(0.93), | |
| 'SQL': pytest.approx(0.88), | |
| 'teamwork': pytest.approx(0.71), | |
| } | |
| def test_empty_text_returns_empty(self): | |
| from apps.accounts.ner import nucha | |
| with patch.object(nucha, '_pipeline', MagicMock()) as m: | |
| assert nucha.layer.predict("") == {} | |
| m.assert_not_called() # shortcut before the pipeline runs | |
| def test_duplicate_spans_keep_max_confidence(self): | |
| from apps.accounts.ner import nucha | |
| fake = MagicMock(return_value=[ | |
| {'entity_group': 'HSKILL', 'score': 0.5, 'word': 'Python'}, | |
| {'entity_group': 'HSKILL', 'score': 0.9, 'word': 'Python'}, | |
| {'entity_group': 'HSKILL', 'score': 0.7, 'word': 'Python'}, | |
| ]) | |
| with patch.object(nucha, '_pipeline', fake): | |
| out = nucha.layer.predict("Python Python Python") | |
| assert out == {'Python': pytest.approx(0.9)} | |
| # --------------------------------------------------------------------------- | |
| # JobBERT layer | |
| # --------------------------------------------------------------------------- | |
| class TestJobBertLayer: | |
| def test_predict_accepts_bio_labels(self): | |
| from apps.accounts.ner import jobbert | |
| fake = MagicMock(return_value=[ | |
| {'entity_group': 'Skill', 'score': 0.87, 'word': 'React'}, | |
| {'entity_group': 'Skill', 'score': 0.81, 'word': 'Docker'}, | |
| # Non-skill label dropped. | |
| {'entity_group': 'O', 'score': 0.99, 'word': 'the'}, | |
| ]) | |
| with patch.object(jobbert, '_pipeline', fake): | |
| out = jobbert.layer.predict("React Docker the project") | |
| assert out == {'React': pytest.approx(0.87), 'Docker': pytest.approx(0.81)} | |
| def test_label_casing_is_ignored(self): | |
| from apps.accounts.ner import jobbert | |
| fake = MagicMock(return_value=[ | |
| {'entity_group': 'SKILL', 'score': 0.9, 'word': 'AWS'}, | |
| {'entity_group': 'skill', 'score': 0.8, 'word': 'GCP'}, | |
| ]) | |
| with patch.object(jobbert, '_pipeline', fake): | |
| out = jobbert.layer.predict("AWS GCP") | |
| assert 'AWS' in out and 'GCP' in out | |
| def test_multitoken_skill_is_merged_via_offsets(self): | |
| """F-NER-FRAG: with character offsets, a skill's B token + adjacent I | |
| tokens re-stitch into one term (jjzha's suffix-less BIO labels stop | |
| aggregation_strategy='simple' from doing this). Two separate B-started | |
| skills stay distinct even when adjacent.""" | |
| from apps.accounts.ner import jobbert | |
| text = "machine learning and Python" | |
| fake = MagicMock(return_value=[ | |
| {'entity_group': 'B', 'score': 0.90, 'word': 'machine', | |
| 'start': 0, 'end': 7}, | |
| {'entity_group': 'I', 'score': 0.80, 'word': 'learning', | |
| 'start': 8, 'end': 16}, # adjacent (gap == 1) β merges | |
| {'entity_group': 'B', 'score': 0.95, 'word': 'Python', | |
| 'start': 21, 'end': 27}, # new B β stays separate | |
| ]) | |
| with patch.object(jobbert, '_pipeline', fake): | |
| out = jobbert.layer.predict(text) | |
| assert out == { | |
| 'machine learning': pytest.approx(0.80), # min score of the group | |
| 'Python': pytest.approx(0.95), | |
| } | |
| def test_adjacent_begin_spans_stay_separate(self): | |
| """Two B-tagged skills with only a space between them are NOT merged β | |
| a 'B' always starts a fresh term.""" | |
| from apps.accounts.ner import jobbert | |
| fake = MagicMock(return_value=[ | |
| {'entity_group': 'B', 'score': 0.9, 'word': 'Python', | |
| 'start': 0, 'end': 6}, | |
| {'entity_group': 'B', 'score': 0.85, 'word': 'SQL', | |
| 'start': 7, 'end': 10}, | |
| ]) | |
| with patch.object(jobbert, '_pipeline', fake): | |
| out = jobbert.layer.predict("Python SQL") | |
| assert out == {'Python': pytest.approx(0.9), 'SQL': pytest.approx(0.85)} | |
| def test_gap_greater_than_one_keeps_tokens_separate(self): | |
| """F-NER-FRAG boundary: a B token and a following I token separated by | |
| more than one char (gap == 2) do NOT merge β pins the upper bound of | |
| the continuation predicate (`0 <= start - cur_end <= 1`).""" | |
| from apps.accounts.ner import jobbert | |
| text = "machine learning" # two spaces β 2-char gap between the spans | |
| fake = MagicMock(return_value=[ | |
| {'entity_group': 'B', 'score': 0.90, 'word': 'machine', | |
| 'start': 0, 'end': 7}, | |
| {'entity_group': 'I', 'score': 0.80, 'word': 'learning', | |
| 'start': 9, 'end': 17}, # gap == 2 β stays separate | |
| ]) | |
| with patch.object(jobbert, '_pipeline', fake): | |
| out = jobbert.layer.predict(text) | |
| assert out == { | |
| 'machine': pytest.approx(0.90), | |
| 'learning': pytest.approx(0.80), | |
| } | |
| def test_bare_bio_prefixes_are_kept(self): | |
| """Regression guard for the label-filter bug in jobbert.py. | |
| The real jjzha/jobbert_skill_extraction model, under | |
| aggregation_strategy="simple", emits entity_group as bare BIO | |
| prefixes ("B" / "I") rather than the entity name. The old filter | |
| (`"skill" not in label`) dropped every span. Since the model has | |
| exactly one entity type (SKILL), every non-O span IS a skill. | |
| """ | |
| from apps.accounts.ner import jobbert | |
| fake = MagicMock(return_value=[ | |
| {'entity_group': 'B', 'score': 0.91, 'word': 'Python'}, | |
| {'entity_group': 'I', 'score': 0.76, 'word': 'Kubernetes'}, | |
| # Outside-span must still be dropped. | |
| {'entity_group': 'O', 'score': 0.99, 'word': 'the'}, | |
| ]) | |
| with patch.object(jobbert, '_pipeline', fake): | |
| out = jobbert.layer.predict("Python and Kubernetes the prod cluster") | |
| assert out == { | |
| 'Python': pytest.approx(0.91), | |
| 'Kubernetes': pytest.approx(0.76), | |
| } | |
| # --------------------------------------------------------------------------- | |
| # SBERT layer β DB-backed via SkillEmbedding + pgvector CosineDistance | |
| # --------------------------------------------------------------------------- | |
| class TestSbertLayer: | |
| """Unit-level: we don't want to hit pgvector here. Stub the ORM path and | |
| verify the layer's own logic (threshold + aggregation).""" | |
| def test_below_threshold_is_dropped(self): | |
| import numpy as np | |
| from apps.accounts.ner import sbert | |
| # Stub noun phrase extraction to avoid loading spacy. | |
| with patch.object(sbert, '_candidate_phrases', return_value=['banana']): | |
| fake_model = MagicMock() | |
| # Real SentenceTransformer.encode returns a numpy ndarray; the | |
| # layer calls .tolist() on it, so mirror the shape here. | |
| fake_model.encode = MagicMock(return_value=np.array([[0.1] * 384])) | |
| with patch.object(sbert, '_get_model', return_value=fake_model): | |
| # pgvector query returns a match whose cosine distance is too | |
| # high (similarity = 1 - 0.5 = 0.5 < 0.65 threshold). | |
| fake_emb = MagicMock() | |
| fake_emb.distance = 0.5 | |
| fake_emb.skill.skill_name = 'Python' | |
| fake_qs = MagicMock() | |
| fake_qs.annotate.return_value.order_by.return_value.select_related.return_value.first.return_value = fake_emb | |
| with patch('apps.skills.models.SkillEmbedding.objects', fake_qs): | |
| out = sbert.layer.predict("I like banana smoothies") | |
| assert out == {} # below threshold β dropped | |
| # --------------------------------------------------------------------------- | |
| # Lexical floor β already covered by test_resume_parser.py; just confirm the | |
| # layer wrapper returns the expected shape. | |
| # --------------------------------------------------------------------------- | |
| class TestLexicalLayer: | |
| def test_wraps_existing_candidate_matches(self): | |
| from apps.accounts.ner import lexical | |
| from apps.skills.models import Skill | |
| Skill.objects.create(skill_name='Python', category='Programming', | |
| difficulty_level='BEGINNER') | |
| Skill.objects.create(skill_name='SQL', category='Database', | |
| difficulty_level='BEGINNER') | |
| out = lexical.layer.predict("Strong Python and SQL background.") | |
| assert set(out) == {'Python', 'SQL'} | |
| assert all(0 < v <= 1.0 for v in out.values()) | |