WA2: packages/matching - similarity, dedupe cluster, keywords extract+coverage, 65 tests with agency-repost fixture triples
This commit is contained in:
parent
05ba99cb4b
commit
069ac454e5
10 changed files with 1135 additions and 0 deletions
40
packages/matching/README.md
Normal file
40
packages/matching/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# packages/matching
|
||||||
|
|
||||||
|
Job posting similarity, dedupe clustering, and keyword coverage for the
|
||||||
|
jobhunt-platform v1.1 agency duplicate detection feature (ADR-0003).
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
### similarity.py
|
||||||
|
- `normalize_employer(name)` -- lowercase, strip agency/legal suffixes (AB, Consulting, etc.), remove punctuation.
|
||||||
|
- `title_score(a, b)` -- rapidfuzz token_set_ratio on job titles (0-100).
|
||||||
|
- `employer_match(a, b)` -- True if normalized employer names are equal.
|
||||||
|
- `desc_score(a, b, max_chars=2000)` -- token_set_ratio on first 2000 chars of descriptions.
|
||||||
|
|
||||||
|
### dedupe.py
|
||||||
|
- `cluster(postings: list[dict]) -> dict[str, list[str]]` -- group postings into duplicate clusters.
|
||||||
|
|
||||||
|
Clustering rule (per ADR-0003):
|
||||||
|
- Same employer (normalized) **OR**
|
||||||
|
- Title similarity >= 85 **AND** description similarity >= 80
|
||||||
|
|
||||||
|
Uses union-find for transitive grouping. Clusters are sorted by descending
|
||||||
|
max pairwise score (`c1` = tightest cluster).
|
||||||
|
|
||||||
|
### keywords.py
|
||||||
|
- `extract_keywords(text, top_n=30)` -- frequency-based keyword extraction with Swedish + English stopword removal.
|
||||||
|
- `coverage(cv_text, posting_text)` -- computes keyword coverage of a CV against a job posting.
|
||||||
|
|
||||||
|
Multiword tech terms like "fast api" are collapsed to "fastapi" so they survive as single keywords.
|
||||||
|
|
||||||
|
## Installation (uv)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/matching
|
||||||
|
uv venv && source .venv/bin/activate
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
MIT
|
||||||
26
packages/matching/pyproject.toml
Normal file
26
packages/matching/pyproject.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[project]
|
||||||
|
name = "matching"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Job posting similarity, dedupe clustering, and keyword coverage for agency duplicate detection."
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"rapidfuzz>=3.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/matching"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
28
packages/matching/src/matching/__init__.py
Normal file
28
packages/matching/src/matching/__init__.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Job posting matching package.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- similarity: normalized text comparison (employer match, title/desc scores)
|
||||||
|
- dedupe: cluster postings into duplicate groups
|
||||||
|
- keywords: keyword extraction and CV-vs-posting coverage
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from matching.similarity import (
|
||||||
|
normalize_employer,
|
||||||
|
title_score,
|
||||||
|
employer_match,
|
||||||
|
desc_score,
|
||||||
|
)
|
||||||
|
from matching.dedupe import cluster
|
||||||
|
from matching.keywords import extract_keywords, coverage
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"normalize_employer",
|
||||||
|
"title_score",
|
||||||
|
"employer_match",
|
||||||
|
"desc_score",
|
||||||
|
"cluster",
|
||||||
|
"extract_keywords",
|
||||||
|
"coverage",
|
||||||
|
]
|
||||||
136
packages/matching/src/matching/dedupe.py
Normal file
136
packages/matching/src/matching/dedupe.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Duplicate clustering for job postings.
|
||||||
|
|
||||||
|
Groups postings into clusters that are likely the same underlying job:
|
||||||
|
- Same employer (normalized) OR
|
||||||
|
- Title similarity >= 85 AND description similarity >= 80
|
||||||
|
|
||||||
|
Output: ``cluster(postings) -> dict[str, list[str]]`` where keys are
|
||||||
|
cluster IDs (``"c1"``, ``"c2"``, ...) sorted by descending max pairwise
|
||||||
|
score within the cluster, and values are lists of posting ``id`` strings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from matching.similarity import employer_match, title_score, desc_score
|
||||||
|
|
||||||
|
# Thresholds per ADR-0003.
|
||||||
|
TITLE_THRESHOLD = 85.0
|
||||||
|
DESC_THRESHOLD = 80.0
|
||||||
|
|
||||||
|
|
||||||
|
def _post_id(p: dict) -> str:
|
||||||
|
"""Extract the id from a posting dict, falling back to str(index)."""
|
||||||
|
pid = p.get("id")
|
||||||
|
if pid is not None:
|
||||||
|
return str(pid)
|
||||||
|
raise ValueError("posting dict must have an 'id' key")
|
||||||
|
|
||||||
|
|
||||||
|
def _are_duplicates(a: dict, b: dict) -> bool:
|
||||||
|
"""Return True if two postings should be in the same cluster.
|
||||||
|
|
||||||
|
Two paths to a match (per ADR-0003 with task-card clarification for
|
||||||
|
the legit-different-jobs-same-agency negative case):
|
||||||
|
|
||||||
|
1. Same employer (normalized) AND some content overlap
|
||||||
|
(title >= 85 OR desc >= 80).
|
||||||
|
This catches agency reposts of the same job while avoiding
|
||||||
|
clustering different jobs that happen to come from the same agency.
|
||||||
|
|
||||||
|
2. Different employer but high title AND desc similarity
|
||||||
|
(title >= 85 AND desc >= 80).
|
||||||
|
This catches cross-agency reposts of the same job.
|
||||||
|
"""
|
||||||
|
ts = title_score(a.get("title", ""), b.get("title", ""))
|
||||||
|
ds = desc_score(a.get("description", ""), b.get("description", ""))
|
||||||
|
same_employer = employer_match(a.get("employer", ""), b.get("employer", ""))
|
||||||
|
|
||||||
|
if same_employer:
|
||||||
|
# Same employer + at least one content dimension similar.
|
||||||
|
return ts >= TITLE_THRESHOLD or ds >= DESC_THRESHOLD
|
||||||
|
|
||||||
|
# Different employer: need both title AND desc to be similar.
|
||||||
|
return ts >= TITLE_THRESHOLD and ds >= DESC_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
def _pair_score(a: dict, b: dict) -> float:
|
||||||
|
"""Compute a similarity score between two postings for sorting clusters."""
|
||||||
|
ts = title_score(a.get("title", ""), b.get("title", ""))
|
||||||
|
ds = desc_score(a.get("description", ""), b.get("description", ""))
|
||||||
|
same_employer = employer_match(a.get("employer", ""), b.get("employer", ""))
|
||||||
|
if same_employer:
|
||||||
|
# Employer match: weight title more for tie-breaking.
|
||||||
|
return 100.0 + ts
|
||||||
|
return (ts + ds) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def cluster(postings: list[dict]) -> dict[str, list[str]]:
|
||||||
|
"""Cluster job postings into duplicate groups.
|
||||||
|
|
||||||
|
Uses union-find so transitive duplicates (A~B, B~C => A~C) are grouped
|
||||||
|
together.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
postings: list of dicts with keys ``id``, ``employer``, ``title``,
|
||||||
|
``description``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping cluster_id (``"c1"``, ``"c2"``, ...) to a list of
|
||||||
|
posting id strings. Clusters are sorted by descending max pairwise
|
||||||
|
score so the tightest cluster gets ``c1``.
|
||||||
|
"""
|
||||||
|
n = len(postings)
|
||||||
|
if n == 0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
ids = [_post_id(p) for p in postings]
|
||||||
|
|
||||||
|
# Union-find (disjoint set).
|
||||||
|
parent: list[int] = list(range(n))
|
||||||
|
|
||||||
|
def find(x: int) -> int:
|
||||||
|
while parent[x] != x:
|
||||||
|
parent[x] = parent[parent[x]]
|
||||||
|
x = parent[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
def union(a: int, b: int) -> None:
|
||||||
|
ra, rb = find(a), find(b)
|
||||||
|
if ra != rb:
|
||||||
|
parent[ra] = rb
|
||||||
|
|
||||||
|
# O(n^2) pairwise comparison.
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
if _are_duplicates(postings[i], postings[j]):
|
||||||
|
union(i, j)
|
||||||
|
|
||||||
|
# Collect groups.
|
||||||
|
groups: dict[int, list[int]] = {}
|
||||||
|
for i in range(n):
|
||||||
|
root = find(i)
|
||||||
|
groups.setdefault(root, []).append(i)
|
||||||
|
|
||||||
|
# Compute max pairwise score per group for sorting.
|
||||||
|
group_scores: list[tuple[float, list[int]]] = []
|
||||||
|
for indices in groups.values():
|
||||||
|
if len(indices) < 2:
|
||||||
|
max_sc = 0.0
|
||||||
|
else:
|
||||||
|
max_sc = 0.0
|
||||||
|
for i in range(len(indices)):
|
||||||
|
for j in range(i + 1, len(indices)):
|
||||||
|
sc = _pair_score(postings[indices[i]], postings[indices[j]])
|
||||||
|
if sc > max_sc:
|
||||||
|
max_sc = sc
|
||||||
|
group_scores.append((max_sc, indices))
|
||||||
|
|
||||||
|
# Sort by descending score; ties broken by first index (stable).
|
||||||
|
group_scores.sort(key=lambda t: (-t[0], t[1][0]))
|
||||||
|
|
||||||
|
# Assign cluster IDs.
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for idx, (_, indices) in enumerate(group_scores, start=1):
|
||||||
|
result[f"c{idx}"] = [ids[i] for i in indices]
|
||||||
|
|
||||||
|
return result
|
||||||
162
packages/matching/src/matching/keywords.py
Normal file
162
packages/matching/src/matching/keywords.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Keyword extraction and coverage analysis.
|
||||||
|
|
||||||
|
Deterministic, no LLM. Uses frequency-based keyword extraction with
|
||||||
|
Swedish and English stopword filtering, and a token-intersection
|
||||||
|
coverage report between a CV and a job posting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stopwords (Swedish + English). Conservative lists.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SWEDISH_STOPWORDS: frozenset[str] = frozenset({
|
||||||
|
"och", "eller", "som", "att", "den", "det", "de", "vi", "ni", "du",
|
||||||
|
"han", "hon", "den", "en", "ett", "ar", "har", "var", "var", "inte",
|
||||||
|
"med", "for", "fran", "till", "pa", "av", "i", "och", "men", "sa",
|
||||||
|
"när", "då", "hur", "alla", "nagon", "nagot", "alla", "manga", "mycket",
|
||||||
|
"skall", "ska", "kan", "kommer", "blir", "vore", "vill", "borde",
|
||||||
|
"efter", "under", "over", "bAKom", "inom", "mellan", "genom", "utan",
|
||||||
|
"mot", "ut", "fran", "frams", "igår", "idag", "imorgon",
|
||||||
|
})
|
||||||
|
|
||||||
|
_ENGLISH_STOPWORDS: frozenset[str] = frozenset({
|
||||||
|
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
|
||||||
|
"of", "with", "by", "from", "is", "are", "was", "were", "be", "been",
|
||||||
|
"being", "have", "has", "had", "do", "does", "did", "will", "would",
|
||||||
|
"should", "could", "may", "might", "must", "can", "this", "that",
|
||||||
|
"these", "those", "i", "you", "he", "she", "it", "we", "they", "me",
|
||||||
|
"him", "her", "us", "them", "my", "your", "his", "its", "our", "their",
|
||||||
|
"what", "which", "who", "whom", "where", "when", "why", "how", "all",
|
||||||
|
"each", "every", "both", "few", "more", "most", "other", "some", "such",
|
||||||
|
"no", "nor", "not", "only", "own", "same", "so", "than", "too", "very",
|
||||||
|
"as", "if", "about", "against", "between", "into", "through", "during",
|
||||||
|
"before", "after", "above", "below", "up", "down", "out", "off",
|
||||||
|
"over", "under", "again", "further", "then", "once", "here", "there",
|
||||||
|
"also", "etc", "e.g", "i.e", "eg", "ie",
|
||||||
|
})
|
||||||
|
|
||||||
|
_STOPWORDS: frozenset[str] = _SWEDISH_STOPWORDS | _ENGLISH_STOPWORDS
|
||||||
|
|
||||||
|
# Tech-relevant multiword patterns: we keep them as single tokens.
|
||||||
|
# e.g. "fast api" -> "fastapi" so it survives as a keyword.
|
||||||
|
_MULTWORD_TECH: list[tuple[str, str]] = [
|
||||||
|
(r"fast\s+api", "fastapi"),
|
||||||
|
(r"machine\s+learning", "machine-learning"),
|
||||||
|
(r"deep\s+learning", "deep-learning"),
|
||||||
|
(r"natural\s+language\s+processing", "nlp"),
|
||||||
|
(r"continuous\s+integration", "ci"),
|
||||||
|
(r"continuous\s+deployment", "cd"),
|
||||||
|
(r"kubernetes", "kubernetes"),
|
||||||
|
(r"react\s+native", "react-native"),
|
||||||
|
(r"node\s+js", "nodejs"),
|
||||||
|
(r"node\.js", "nodejs"),
|
||||||
|
(r"aws", "aws"),
|
||||||
|
(r"gcp", "gcp"),
|
||||||
|
(r"ci/cd", "ci-cd"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Minimum token length for keywords.
|
||||||
|
_MIN_TOKEN_LEN = 2
|
||||||
|
|
||||||
|
# Tokenizer: split on non-alphanumeric.
|
||||||
|
_TOKEN_RE = re.compile(r"[a-z0-9+#.\-/]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess_multiwords(text: str) -> str:
|
||||||
|
"""Replace known multiword tech terms with single tokens."""
|
||||||
|
result = text.lower()
|
||||||
|
for pattern, replacement in _MULTWORD_TECH:
|
||||||
|
result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(text: str) -> list[str]:
|
||||||
|
"""Tokenize text into lowercased tokens."""
|
||||||
|
text = _preprocess_multiwords(text)
|
||||||
|
raw_tokens = _TOKEN_RE.findall(text.lower())
|
||||||
|
tokens: list[str] = []
|
||||||
|
for t in raw_tokens:
|
||||||
|
t = t.strip(".-/")
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
if t in _STOPWORDS:
|
||||||
|
continue
|
||||||
|
if len(t) < _MIN_TOKEN_LEN:
|
||||||
|
continue
|
||||||
|
# Skip pure numbers (unless they look like versions).
|
||||||
|
if t.isdigit() and len(t) > 4:
|
||||||
|
continue
|
||||||
|
tokens.append(t)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def extract_keywords(text: str, top_n: int = 30) -> list[str]:
|
||||||
|
"""Extract the top *top_n* keywords from *text* by frequency.
|
||||||
|
|
||||||
|
Keywords are lowercased tokens. Stopwords (Swedish + English) are
|
||||||
|
removed. Multiword tech terms like "fast api" are collapsed to
|
||||||
|
"fastapi".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: the text to analyze.
|
||||||
|
top_n: maximum number of keywords to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of keyword strings, most frequent first. Ties are broken
|
||||||
|
alphabetically for determinism.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
tokens = _tokenize(text)
|
||||||
|
if not tokens:
|
||||||
|
return []
|
||||||
|
counts: Counter[str] = Counter(tokens)
|
||||||
|
# Sort by count desc, then alphabetically for deterministic order.
|
||||||
|
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||||
|
return [word for word, _ in ranked[:top_n]]
|
||||||
|
|
||||||
|
|
||||||
|
def coverage(cv_text: str, posting_text: str) -> dict[str, list[str] | float]:
|
||||||
|
"""Compute keyword coverage of a CV against a job posting.
|
||||||
|
|
||||||
|
Extracts keywords from *posting_text*, extracts keywords from
|
||||||
|
*cv_text*, and reports which posting keywords are matched in the
|
||||||
|
CV, which are missing, and the coverage ratio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cv_text: the candidate's CV text.
|
||||||
|
posting_text: the job posting text.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys:
|
||||||
|
- ``matched``: list of posting keywords found in CV.
|
||||||
|
- ``missing``: list of posting keywords NOT found in CV.
|
||||||
|
- ``ratio``: float (matched / total), 0.0 if no keywords.
|
||||||
|
"""
|
||||||
|
posting_kw = extract_keywords(posting_text, top_n=30)
|
||||||
|
if not posting_kw:
|
||||||
|
return {"matched": [], "missing": [], "ratio": 0.0}
|
||||||
|
|
||||||
|
cv_kw_set: set[str] = set(extract_keywords(cv_text, top_n=200))
|
||||||
|
|
||||||
|
matched: list[str] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
for kw in posting_kw:
|
||||||
|
if kw in cv_kw_set:
|
||||||
|
matched.append(kw)
|
||||||
|
else:
|
||||||
|
missing.append(kw)
|
||||||
|
|
||||||
|
total = len(posting_kw)
|
||||||
|
ratio = len(matched) / total if total > 0 else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"matched": matched,
|
||||||
|
"missing": missing,
|
||||||
|
"ratio": ratio,
|
||||||
|
}
|
||||||
113
packages/matching/src/matching/similarity.py
Normal file
113
packages/matching/src/matching/similarity.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Similarity helpers for job postings.
|
||||||
|
|
||||||
|
Uses rapidfuzz for fuzzy string matching. All functions are deterministic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from rapidfuzz import fuzz
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agency-suffix / noise words to strip from employer names.
|
||||||
|
# Conservative list: only legal-form suffixes and common consulting words.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_AGENCY_SUFFIXES: list[str] = [
|
||||||
|
# Swedish legal forms
|
||||||
|
"ab",
|
||||||
|
"aktiebolag",
|
||||||
|
"hb",
|
||||||
|
"kb",
|
||||||
|
"ekonomisk forening",
|
||||||
|
# English legal forms
|
||||||
|
"inc",
|
||||||
|
"corp",
|
||||||
|
"corporation",
|
||||||
|
"ltd",
|
||||||
|
"limited",
|
||||||
|
"llc",
|
||||||
|
"gmbh",
|
||||||
|
"ag",
|
||||||
|
"sas",
|
||||||
|
"sarl",
|
||||||
|
# Consulting / staffing suffixes (agency hints)
|
||||||
|
"consulting",
|
||||||
|
"consultancy",
|
||||||
|
"consult",
|
||||||
|
"recruitment",
|
||||||
|
"staffing",
|
||||||
|
"solutions",
|
||||||
|
"services",
|
||||||
|
"group",
|
||||||
|
"partners",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pre-compile regex for trailing suffix removal.
|
||||||
|
_SUFFIX_RE = re.compile(
|
||||||
|
r"\s+(" + "|".join(re.escape(s) for s in _AGENCY_SUFFIXES) + r")\.?\s*$",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Characters to collapse: punctuation -> space, then multi-space -> single.
|
||||||
|
_PUNCT_RE = re.compile(r"[^\w\s]")
|
||||||
|
_WS_RE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_employer(name: str) -> str:
|
||||||
|
"""Normalize an employer/company name for comparison.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. lowercase
|
||||||
|
2. strip trailing agency/legal suffixes (ab, consulting, etc.)
|
||||||
|
3. remove punctuation
|
||||||
|
4. collapse whitespace
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> normalize_employer("Acme Consulting AB")
|
||||||
|
'acme'
|
||||||
|
>>> normalize_employer("Acme AB")
|
||||||
|
'acme'
|
||||||
|
>>> normalize_employer(" Globex Corp. ")
|
||||||
|
'globex'
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
s = name.strip().lower()
|
||||||
|
# Strip trailing suffix (may need multiple passes for "Consulting AB").
|
||||||
|
for _ in range(3):
|
||||||
|
new = _SUFFIX_RE.sub("", s)
|
||||||
|
if new == s:
|
||||||
|
break
|
||||||
|
s = new
|
||||||
|
# Remove punctuation.
|
||||||
|
s = _PUNCT_RE.sub(" ", s)
|
||||||
|
s = _WS_RE.sub(" ", s).strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def title_score(a: str, b: str) -> float:
|
||||||
|
"""Token-set ratio score for two job titles (0-100).
|
||||||
|
|
||||||
|
Uses rapidfuzz ``fuzz.token_set_ratio`` which is order-independent
|
||||||
|
and handles subsets well.
|
||||||
|
"""
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
return float(fuzz.token_set_ratio(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def employer_match(a: str, b: str) -> bool:
|
||||||
|
"""True if two employer names normalize to the same string."""
|
||||||
|
return normalize_employer(a) == normalize_employer(b) and normalize_employer(a) != ""
|
||||||
|
|
||||||
|
|
||||||
|
def desc_score(a: str, b: str, *, max_chars: int = 2000) -> float:
|
||||||
|
"""Token-set ratio for job descriptions, comparing first *max_chars* chars.
|
||||||
|
|
||||||
|
Truncating avoids very long descriptions dominating the score and
|
||||||
|
keeps computation fast.
|
||||||
|
"""
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
return float(fuzz.token_set_ratio(a[:max_chars], b[:max_chars]))
|
||||||
184
packages/matching/tests/conftest.py
Normal file
184
packages/matching/tests/conftest.py
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
"""Shared fixtures for matching tests.
|
||||||
|
|
||||||
|
Provides agency-repost fixture triples and a legit-different-jobs-same-agency
|
||||||
|
negative case.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agency-repost fixture triples.
|
||||||
|
# Three realistic scenarios where agencies repost the same job.
|
||||||
|
# Each triple is a list of 3 posting dicts that should all cluster together.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Triple 1: Same role, two agencies + direct employer posting.
|
||||||
|
# All three have the same title and nearly identical description, but
|
||||||
|
# different employer names (the two agencies vs the actual company).
|
||||||
|
TRIPLE_1_AGENCY_REPOST = [
|
||||||
|
{
|
||||||
|
"id": "t1-a",
|
||||||
|
"employer": "TechCorp AB",
|
||||||
|
"title": "Senior Python Developer",
|
||||||
|
"description": (
|
||||||
|
"We are looking for a Senior Python Developer to join our backend team. "
|
||||||
|
"You will work with Fast API, PostgreSQL, and Docker in a cloud-native "
|
||||||
|
"environment. 5+ years of Python experience required. "
|
||||||
|
"Experience with AWS and Kubernetes is a plus."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t1-b",
|
||||||
|
"employer": "Nordic IT Consulting AB",
|
||||||
|
"title": "Senior Python Developer",
|
||||||
|
"description": (
|
||||||
|
"We are looking for a Senior Python Developer to join our backend team. "
|
||||||
|
"You will work with Fast API, PostgreSQL, and Docker in a cloud-native "
|
||||||
|
"environment. 5+ years of Python experience required. "
|
||||||
|
"Experience with AWS and Kubernetes is a plus."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t1-c",
|
||||||
|
"employer": "Acme Recruitment Group",
|
||||||
|
"title": "Senior Python Developer",
|
||||||
|
"description": (
|
||||||
|
"We are looking for a Senior Python Developer to join our backend team. "
|
||||||
|
"You will work with Fast API, PostgreSQL, and Docker in a cloud-native "
|
||||||
|
"environment. 5+ years of Python experience required. "
|
||||||
|
"Experience with AWS and Kubernetes is a plus."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Triple 2: Same role reposted by same agency with minor wording variations.
|
||||||
|
TRIPLE_2_SAME_AGENCY_REPOST = [
|
||||||
|
{
|
||||||
|
"id": "t2-a",
|
||||||
|
"employer": "Stockholm Tech Staffing AB",
|
||||||
|
"title": "Fullstack Engineer",
|
||||||
|
"description": (
|
||||||
|
"Fullstack Engineer wanted for a fintech startup in Stockholm. "
|
||||||
|
"Tech stack: React, TypeScript, Node.js, PostgreSQL. "
|
||||||
|
"You will build customer-facing features and internal tools. "
|
||||||
|
"Must have experience with CI/CD pipelines."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t2-b",
|
||||||
|
"employer": "Stockholm Tech Staffing AB",
|
||||||
|
"title": "Fullstack Engineer",
|
||||||
|
"description": (
|
||||||
|
"Fullstack Engineer wanted for a fintech startup in Stockholm. "
|
||||||
|
"Tech stack: React, TypeScript, Node.js, PostgreSQL. "
|
||||||
|
"You will build customer-facing features and internal tools. "
|
||||||
|
"Must have experience with CI/CD pipelines."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t2-c",
|
||||||
|
"employer": "Stockholm Tech Staffing",
|
||||||
|
"title": "Fullstack Engineer",
|
||||||
|
"description": (
|
||||||
|
"Fullstack Engineer wanted for a fintech startup in Stockholm. "
|
||||||
|
"Tech stack: React, TypeScript, Node.js, PostgreSQL. "
|
||||||
|
"You will build customer-facing features and internal tools. "
|
||||||
|
"Must have experience with CI/CD pipelines."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Triple 3: Same role, slightly different title but same description body.
|
||||||
|
# Different employers (agencies), high title + desc similarity.
|
||||||
|
TRIPLE_3_CROSS_AGENCY = [
|
||||||
|
{
|
||||||
|
"id": "t3-a",
|
||||||
|
"employer": "Data Recruiting Solutions",
|
||||||
|
"title": "Data Engineer",
|
||||||
|
"description": (
|
||||||
|
"We seek a Data Engineer to build and maintain ETL pipelines using "
|
||||||
|
"Python, Airflow, dbt, and Snowflake. You will design data models, "
|
||||||
|
"optimize queries, and ensure data quality. Experience with "
|
||||||
|
"distributed systems and Spark is required."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t3-b",
|
||||||
|
"employer": "Cloud Talent Partners",
|
||||||
|
"title": "Data Engineer",
|
||||||
|
"description": (
|
||||||
|
"We seek a Data Engineer to build and maintain ETL pipelines using "
|
||||||
|
"Python, Airflow, dbt, and Snowflake. You will design data models, "
|
||||||
|
"optimize queries, and ensure data quality. Experience with "
|
||||||
|
"distributed systems and Spark is required."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t3-c",
|
||||||
|
"employer": "Analytics Staffing Ltd",
|
||||||
|
"title": "Data Engineer",
|
||||||
|
"description": (
|
||||||
|
"We seek a Data Engineer to build and maintain ETL pipelines using "
|
||||||
|
"Python, Airflow, dbt, and Snowflake. You will design data models, "
|
||||||
|
"optimize queries, and ensure data quality. Experience with "
|
||||||
|
"distributed systems and Spark is required."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NEGATIVE case: legit different jobs at same agency -- must NOT cluster.
|
||||||
|
# Same agency employer but different titles and different descriptions.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
NEGATIVE_DIFFERENT_JOBS_SAME_AGENCY = [
|
||||||
|
{
|
||||||
|
"id": "neg-a",
|
||||||
|
"employer": "Nordic IT Consulting AB",
|
||||||
|
"title": "Frontend Developer",
|
||||||
|
"description": (
|
||||||
|
"We are looking for a Frontend Developer with expertise in React "
|
||||||
|
"and TypeScript. You will build responsive web applications and "
|
||||||
|
"work closely with our design team. Experience with CSS-in-JS and "
|
||||||
|
"accessibility standards is required."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "neg-b",
|
||||||
|
"employer": "Nordic IT Consulting AB",
|
||||||
|
"title": "DevOps Engineer",
|
||||||
|
"description": (
|
||||||
|
"We need a DevOps Engineer to manage our Kubernetes clusters and "
|
||||||
|
"CI/CD pipelines. You will work with Terraform, ArgoCD, and "
|
||||||
|
"Prometheus monitoring. Strong Linux and networking background "
|
||||||
|
"is required. AWS certification is a plus."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def triple1():
|
||||||
|
"""Agency repost triple 1: same role, two agencies + employer."""
|
||||||
|
return [dict(p) for p in TRIPLE_1_AGENCY_REPOST]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def triple2():
|
||||||
|
"""Agency repost triple 2: same agency reposts same job."""
|
||||||
|
return [dict(p) for p in TRIPLE_2_SAME_AGENCY_REPOST]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def triple3():
|
||||||
|
"""Agency repost triple 3: cross-agency same role same description."""
|
||||||
|
return [dict(p) for p in TRIPLE_3_CROSS_AGENCY]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def negative_same_agency():
|
||||||
|
"""Negative case: different jobs at same agency, must NOT cluster."""
|
||||||
|
return [dict(p) for p in NEGATIVE_DIFFERENT_JOBS_SAME_AGENCY]
|
||||||
168
packages/matching/tests/test_dedupe.py
Normal file
168
packages/matching/tests/test_dedupe.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""Tests for dedupe clustering."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from matching.dedupe import cluster
|
||||||
|
|
||||||
|
|
||||||
|
class TestClusterBasic:
|
||||||
|
def test_empty_list(self):
|
||||||
|
assert cluster([]) == {}
|
||||||
|
|
||||||
|
def test_single_posting(self):
|
||||||
|
result = cluster([
|
||||||
|
{"id": "a", "employer": "Acme AB", "title": "Dev", "description": "x"}
|
||||||
|
])
|
||||||
|
assert len(result) == 1
|
||||||
|
assert "c1" in result
|
||||||
|
assert result["c1"] == ["a"]
|
||||||
|
|
||||||
|
def test_no_duplicates_separate_clusters(self):
|
||||||
|
postings = [
|
||||||
|
{"id": "a", "employer": "Acme AB", "title": "Python Dev", "description": "Python backend"},
|
||||||
|
{"id": "b", "employer": "Globex AB", "title": "React Dev", "description": "React frontend"},
|
||||||
|
{"id": "c", "employer": "Foo Ltd", "title": "Data Scientist", "description": "ML pipelines"},
|
||||||
|
]
|
||||||
|
result = cluster(postings)
|
||||||
|
# Each posting in its own cluster.
|
||||||
|
total_ids = sum(len(v) for v in result.values())
|
||||||
|
assert total_ids == 3
|
||||||
|
# All cluster values are singletons.
|
||||||
|
for ids in result.values():
|
||||||
|
assert len(ids) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestClusterEmployerMatch:
|
||||||
|
def test_same_employer_clusters(self):
|
||||||
|
"""Same employer name (normalized) with similar content should cluster."""
|
||||||
|
postings = [
|
||||||
|
{"id": "a", "employer": "Acme AB", "title": "Python Developer", "description": "Python backend development"},
|
||||||
|
{"id": "b", "employer": "Acme AB", "title": "Python Developer", "description": "Python backend development senior"},
|
||||||
|
]
|
||||||
|
result = cluster(postings)
|
||||||
|
# Same employer + similar title -> same cluster.
|
||||||
|
assert len(result) == 1
|
||||||
|
assert set(result["c1"]) == {"a", "b"}
|
||||||
|
|
||||||
|
def test_same_employer_different_content_no_cluster(self):
|
||||||
|
"""Same employer but completely different titles/descriptions should NOT cluster."""
|
||||||
|
postings = [
|
||||||
|
{"id": "a", "employer": "Acme AB", "title": "Python Developer", "description": "We need a Python developer for backend work."},
|
||||||
|
{"id": "b", "employer": "Acme AB", "title": "Chef", "description": "Looking for a head chef for our restaurant kitchen."},
|
||||||
|
]
|
||||||
|
result = cluster(postings)
|
||||||
|
# Same employer but different jobs -> no cluster.
|
||||||
|
assert all(len(v) == 1 for v in result.values())
|
||||||
|
|
||||||
|
def test_employer_suffix_variations_cluster(self):
|
||||||
|
"""Acme AB and Acme should cluster (normalized match)."""
|
||||||
|
postings = [
|
||||||
|
{"id": "a", "employer": "Acme AB", "title": "Dev", "description": "x"},
|
||||||
|
{"id": "b", "employer": "Acme", "title": "Dev", "description": "y"},
|
||||||
|
]
|
||||||
|
result = cluster(postings)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert set(result["c1"]) == {"a", "b"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestClusterTitleDescMatch:
|
||||||
|
def test_title_desc_high_enough(self):
|
||||||
|
"""Different employers but title >= 85 and desc >= 80 -> cluster."""
|
||||||
|
desc = (
|
||||||
|
"We are looking for a Senior Python Developer to join our backend "
|
||||||
|
"team. You will work with Fast API, PostgreSQL, and Docker."
|
||||||
|
)
|
||||||
|
postings = [
|
||||||
|
{"id": "a", "employer": "Agency One AB", "title": "Senior Python Developer", "description": desc},
|
||||||
|
{"id": "b", "employer": "Agency Two AB", "title": "Senior Python Developer", "description": desc},
|
||||||
|
]
|
||||||
|
result = cluster(postings)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert set(result["c1"]) == {"a", "b"}
|
||||||
|
|
||||||
|
def test_title_high_desc_low_no_cluster(self):
|
||||||
|
"""Title similar but desc too different -> no cluster."""
|
||||||
|
postings = [
|
||||||
|
{"id": "a", "employer": "Agency A", "title": "Python Developer", "description": "We need a Python developer for backend work with Django."},
|
||||||
|
{"id": "b", "employer": "Agency B", "title": "Python Developer", "description": "Looking for someone to teach Python to high school students."},
|
||||||
|
]
|
||||||
|
result = cluster(postings)
|
||||||
|
# Should NOT cluster (different employers, low desc score).
|
||||||
|
assert len(result) == 2 or all(len(v) == 1 for v in result.values())
|
||||||
|
|
||||||
|
|
||||||
|
class TestClusterTransitive:
|
||||||
|
def test_transitive_clustering(self):
|
||||||
|
"""If A~B and B~C then A~C should be in same cluster."""
|
||||||
|
# A and B same employer + similar title, B and C same employer + similar title.
|
||||||
|
postings = [
|
||||||
|
{"id": "a", "employer": "Acme AB", "title": "Python Developer", "description": "Python backend development"},
|
||||||
|
{"id": "b", "employer": "Acme AB", "title": "Python Developer", "description": "Python backend development senior"},
|
||||||
|
{"id": "c", "employer": "Acme AB", "title": "Python Developer", "description": "Python backend development lead"},
|
||||||
|
]
|
||||||
|
result = cluster(postings)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert set(result["c1"]) == {"a", "b", "c"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestClusterSorting:
|
||||||
|
def test_cluster_ids_sorted_by_score(self):
|
||||||
|
"""Cluster with higher pairwise score should get c1."""
|
||||||
|
# Tight cluster: identical titles and descriptions (different employers).
|
||||||
|
tight_desc = "Python backend developer with Fast API and PostgreSQL and Docker and AWS and Kubernetes."
|
||||||
|
# Looser cluster: different employers, high title but lower desc similarity.
|
||||||
|
loose_desc_1 = "Python data engineering and pipelines with ETL tools."
|
||||||
|
loose_desc_2 = "Python data engineering and ETL work with Airflow."
|
||||||
|
postings = [
|
||||||
|
# tight cluster (different employers, high title+desc)
|
||||||
|
{"id": "t1", "employer": "Agency A", "title": "Python Developer", "description": tight_desc},
|
||||||
|
{"id": "t2", "employer": "Agency B", "title": "Python Developer", "description": tight_desc},
|
||||||
|
# loose cluster (different employers, high title but lower desc)
|
||||||
|
{"id": "l1", "employer": "Agency C", "title": "Python Developer", "description": loose_desc_1},
|
||||||
|
{"id": "l2", "employer": "Agency D", "title": "Python Developer", "description": loose_desc_2},
|
||||||
|
]
|
||||||
|
result = cluster(postings)
|
||||||
|
# Both clusters should exist.
|
||||||
|
all_ids = set()
|
||||||
|
for ids in result.values():
|
||||||
|
all_ids.update(ids)
|
||||||
|
assert all_ids == {"t1", "t2", "l1", "l2"}
|
||||||
|
# c1 should be the tight cluster (higher score: title+desc both 100).
|
||||||
|
assert set(result["c1"]) == {"t1", "t2"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgencyRepostTriples:
|
||||||
|
"""Test the 3 agency-repost fixture triples."""
|
||||||
|
|
||||||
|
def test_triple1_all_cluster(self, triple1):
|
||||||
|
"""Triple 1: 3 postings of same role via different employers cluster."""
|
||||||
|
result = cluster(triple1)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert set(result["c1"]) == {"t1-a", "t1-b", "t1-c"}
|
||||||
|
|
||||||
|
def test_triple2_all_cluster(self, triple2):
|
||||||
|
"""Triple 2: same agency reposts same job (suffix variations)."""
|
||||||
|
result = cluster(triple2)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert set(result["c1"]) == {"t2-a", "t2-b", "t2-c"}
|
||||||
|
|
||||||
|
def test_triple3_all_cluster(self, triple3):
|
||||||
|
"""Triple 3: cross-agency same role same description."""
|
||||||
|
result = cluster(triple3)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert set(result["c1"]) == {"t3-a", "t3-b", "t3-c"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegativeSameAgencyDifferentJobs:
|
||||||
|
"""Negative case: different jobs at same agency must NOT cluster."""
|
||||||
|
|
||||||
|
def test_different_jobs_same_agency_no_cluster(self, negative_same_agency):
|
||||||
|
"""Different jobs at same agency must NOT cluster.
|
||||||
|
|
||||||
|
Same employer but completely different titles and descriptions.
|
||||||
|
Per the clustering rule, same employer alone is not sufficient;
|
||||||
|
some content overlap (title >= 85 OR desc >= 80) is also required.
|
||||||
|
"""
|
||||||
|
result = cluster(negative_same_agency)
|
||||||
|
all_singletons = all(len(v) == 1 for v in result.values())
|
||||||
|
assert all_singletons, "Different jobs at same agency should not cluster"
|
||||||
152
packages/matching/tests/test_keywords.py
Normal file
152
packages/matching/tests/test_keywords.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""Tests for keyword extraction and coverage."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from matching.keywords import extract_keywords, coverage
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractKeywords:
|
||||||
|
def test_basic_extraction(self):
|
||||||
|
text = "Python developer with Fast API experience and PostgreSQL database skills."
|
||||||
|
kws = extract_keywords(text)
|
||||||
|
assert "python" in kws
|
||||||
|
assert "fastapi" in kws
|
||||||
|
assert "postgresql" in kws
|
||||||
|
|
||||||
|
def test_stopwords_removed(self):
|
||||||
|
text = "We are looking for a developer with experience in Python."
|
||||||
|
kws = extract_keywords(text)
|
||||||
|
assert "we" not in kws
|
||||||
|
assert "are" not in kws
|
||||||
|
assert "for" not in kws
|
||||||
|
assert "a" not in kws
|
||||||
|
assert "in" not in kws
|
||||||
|
assert "python" in kws
|
||||||
|
assert "developer" in kws
|
||||||
|
|
||||||
|
def test_swedish_stopwords_removed(self):
|
||||||
|
text = "Vi letar efter en Python utvecklare med erfarenhet av Docker."
|
||||||
|
kws = extract_keywords(text)
|
||||||
|
assert "vi" not in kws
|
||||||
|
assert "en" not in kws
|
||||||
|
assert "av" not in kws
|
||||||
|
assert "python" in kws
|
||||||
|
assert "docker" in kws
|
||||||
|
|
||||||
|
def test_top_n_limit(self):
|
||||||
|
text = "python python python docker docker docker kubernetes kubernetes kubernetes react react react"
|
||||||
|
kws = extract_keywords(text, top_n=2)
|
||||||
|
assert len(kws) == 2
|
||||||
|
|
||||||
|
def test_empty_text(self):
|
||||||
|
assert extract_keywords("") == []
|
||||||
|
|
||||||
|
def test_whitespace_only(self):
|
||||||
|
assert extract_keywords(" ") == []
|
||||||
|
|
||||||
|
def test_multiword_fastapi(self):
|
||||||
|
text = "Experience with fast api framework for building REST APIs."
|
||||||
|
kws = extract_keywords(text)
|
||||||
|
assert "fastapi" in kws
|
||||||
|
|
||||||
|
def test_multiword_machine_learning(self):
|
||||||
|
text = "machine learning models for predictive analytics."
|
||||||
|
kws = extract_keywords(text)
|
||||||
|
assert "machine-learning" in kws
|
||||||
|
|
||||||
|
def test_frequency_ordering(self):
|
||||||
|
text = "python python python docker docker kubernetes"
|
||||||
|
kws = extract_keywords(text, top_n=3)
|
||||||
|
assert kws[0] == "python"
|
||||||
|
assert kws[1] == "docker"
|
||||||
|
assert kws[2] == "kubernetes"
|
||||||
|
|
||||||
|
def test_deterministic_tie_breaking(self):
|
||||||
|
"""Ties in frequency should be broken alphabetically."""
|
||||||
|
text = "docker kubernetes"
|
||||||
|
kws = extract_keywords(text)
|
||||||
|
# Both have frequency 1, so alphabetical: docker < kubernetes
|
||||||
|
assert kws[0] == "docker"
|
||||||
|
assert kws[1] == "kubernetes"
|
||||||
|
|
||||||
|
def test_min_token_length(self):
|
||||||
|
text = "x y z aa bb cc developer"
|
||||||
|
kws = extract_keywords(text)
|
||||||
|
assert "x" not in kws
|
||||||
|
assert "y" not in kws
|
||||||
|
assert "z" not in kws
|
||||||
|
assert "developer" in kws
|
||||||
|
|
||||||
|
def test_tech_terms_preserved(self):
|
||||||
|
text = "Node.js and React Native for mobile development."
|
||||||
|
kws = extract_keywords(text)
|
||||||
|
assert "nodejs" in kws
|
||||||
|
assert "react-native" in kws
|
||||||
|
|
||||||
|
|
||||||
|
class TestCoverage:
|
||||||
|
def test_full_coverage(self):
|
||||||
|
cv = "Python developer with Fast API PostgreSQL Docker AWS Kubernetes"
|
||||||
|
posting = "Python developer with Fast API PostgreSQL Docker AWS Kubernetes"
|
||||||
|
result = coverage(cv, posting)
|
||||||
|
assert result["ratio"] == 1.0
|
||||||
|
assert len(result["missing"]) == 0
|
||||||
|
|
||||||
|
def test_partial_coverage(self):
|
||||||
|
cv = "Python developer with PostgreSQL and Docker experience"
|
||||||
|
posting = "Python developer with Fast API PostgreSQL Docker AWS Kubernetes React"
|
||||||
|
result = coverage(cv, posting)
|
||||||
|
assert 0.0 < result["ratio"] < 1.0
|
||||||
|
assert "python" in result["matched"]
|
||||||
|
assert "postgresql" in result["matched"]
|
||||||
|
assert "docker" in result["matched"]
|
||||||
|
assert "fastapi" in result["missing"]
|
||||||
|
assert "kubernetes" in result["missing"]
|
||||||
|
|
||||||
|
def test_zero_coverage(self):
|
||||||
|
cv = "Chef with experience in French cuisine and menu planning"
|
||||||
|
posting = "Python developer with Fast API PostgreSQL Docker"
|
||||||
|
result = coverage(cv, posting)
|
||||||
|
assert result["ratio"] == 0.0
|
||||||
|
assert len(result["matched"]) == 0
|
||||||
|
assert len(result["missing"]) > 0
|
||||||
|
|
||||||
|
def test_empty_posting(self):
|
||||||
|
result = coverage("Python developer", "")
|
||||||
|
assert result == {"matched": [], "missing": [], "ratio": 0.0}
|
||||||
|
|
||||||
|
def test_empty_cv(self):
|
||||||
|
result = coverage("", "Python developer with Docker")
|
||||||
|
assert result["ratio"] == 0.0
|
||||||
|
assert len(result["matched"]) == 0
|
||||||
|
assert len(result["missing"]) > 0
|
||||||
|
|
||||||
|
def test_both_empty(self):
|
||||||
|
result = coverage("", "")
|
||||||
|
assert result == {"matched": [], "missing": [], "ratio": 0.0}
|
||||||
|
|
||||||
|
def test_ratio_calculation(self):
|
||||||
|
cv = "python docker postgresql"
|
||||||
|
posting = "python docker postgresql kubernetes"
|
||||||
|
result = coverage(cv, posting)
|
||||||
|
# 3 of 4 matched (approx, depends on stopword filtering).
|
||||||
|
assert result["ratio"] > 0.5
|
||||||
|
assert result["ratio"] <= 1.0
|
||||||
|
|
||||||
|
def test_matched_and_missing_lists(self):
|
||||||
|
cv = "python docker"
|
||||||
|
posting = "python docker kubernetes react"
|
||||||
|
result = coverage(cv, posting)
|
||||||
|
assert "python" in result["matched"]
|
||||||
|
assert "docker" in result["matched"]
|
||||||
|
assert "kubernetes" in result["missing"]
|
||||||
|
assert "react" in result["missing"]
|
||||||
|
|
||||||
|
def test_coverage_returns_dict_keys(self):
|
||||||
|
result = coverage("python", "python docker")
|
||||||
|
assert "matched" in result
|
||||||
|
assert "missing" in result
|
||||||
|
assert "ratio" in result
|
||||||
|
assert isinstance(result["matched"], list)
|
||||||
|
assert isinstance(result["missing"], list)
|
||||||
|
assert isinstance(result["ratio"], float)
|
||||||
126
packages/matching/tests/test_similarity.py
Normal file
126
packages/matching/tests/test_similarity.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""Tests for similarity helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from matching.similarity import (
|
||||||
|
normalize_employer,
|
||||||
|
title_score,
|
||||||
|
employer_match,
|
||||||
|
desc_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeEmployer:
|
||||||
|
def test_simple_lowercase(self):
|
||||||
|
assert normalize_employer("Acme") == "acme"
|
||||||
|
|
||||||
|
def test_strips_swedish_ab(self):
|
||||||
|
assert normalize_employer("Acme AB") == "acme"
|
||||||
|
|
||||||
|
def test_strips_aktiebolag(self):
|
||||||
|
assert normalize_employer("Acme Aktiebolag") == "acme"
|
||||||
|
|
||||||
|
def test_strips_consulting_suffix(self):
|
||||||
|
assert normalize_employer("Nordic IT Consulting AB") == "nordic it"
|
||||||
|
|
||||||
|
def test_strips_corp_suffix(self):
|
||||||
|
assert normalize_employer("Globex Corp.") == "globex"
|
||||||
|
|
||||||
|
def test_strips_ltd_suffix(self):
|
||||||
|
assert normalize_employer("Foo Ltd") == "foo"
|
||||||
|
|
||||||
|
def test_strips_recruitment_suffix(self):
|
||||||
|
assert normalize_employer("Acme Recruitment Group") == "acme"
|
||||||
|
|
||||||
|
def test_strips_multiple_suffixes(self):
|
||||||
|
# "Consulting AB" should strip both "AB" then "Consulting"
|
||||||
|
assert normalize_employer("Nordic Consulting AB") == "nordic"
|
||||||
|
|
||||||
|
def test_removes_punctuation(self):
|
||||||
|
assert normalize_employer("Acme, Inc.") == "acme"
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert normalize_employer("") == ""
|
||||||
|
|
||||||
|
def test_whitespace_only(self):
|
||||||
|
assert normalize_employer(" ") == ""
|
||||||
|
|
||||||
|
def test_preserves_core_name_with_special_chars(self):
|
||||||
|
result = normalize_employer("Café Nu AB")
|
||||||
|
assert "café" in result or "cafe" in result
|
||||||
|
|
||||||
|
def test_dots_in_name_preserved(self):
|
||||||
|
# Punctuation (except & which gets stripped) is removed; H&M -> h m
|
||||||
|
result = normalize_employer("H&M AB")
|
||||||
|
assert result == "h m"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTitleScore:
|
||||||
|
def test_identical_titles(self):
|
||||||
|
assert title_score("Senior Python Developer", "Senior Python Developer") == 100.0
|
||||||
|
|
||||||
|
def test_similar_titles_high_score(self):
|
||||||
|
score = title_score("Python Developer", "Senior Python Developer")
|
||||||
|
assert score >= 85.0
|
||||||
|
|
||||||
|
def test_different_titles_low_score(self):
|
||||||
|
score = title_score("Python Developer", "Frontend Designer")
|
||||||
|
assert score < 50.0
|
||||||
|
|
||||||
|
def test_empty_title(self):
|
||||||
|
assert title_score("", "Something") == 0.0
|
||||||
|
|
||||||
|
def test_both_empty(self):
|
||||||
|
assert title_score("", "") == 0.0
|
||||||
|
|
||||||
|
def test_order_independent(self):
|
||||||
|
# token_set_ratio is order-independent
|
||||||
|
a = "Senior Python Developer"
|
||||||
|
b = "Developer Python Senior"
|
||||||
|
assert title_score(a, b) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmployerMatch:
|
||||||
|
def test_same_name_matches(self):
|
||||||
|
assert employer_match("Acme AB", "Acme AB") is True
|
||||||
|
|
||||||
|
def test_suffix_variation_matches(self):
|
||||||
|
assert employer_match("Acme AB", "Acme") is True
|
||||||
|
|
||||||
|
def test_different_employers_no_match(self):
|
||||||
|
assert employer_match("Acme AB", "Globex AB") is False
|
||||||
|
|
||||||
|
def test_consulting_variations_match(self):
|
||||||
|
assert employer_match("Nordic IT Consulting AB", "Nordic IT") is True
|
||||||
|
|
||||||
|
def test_empty_no_match(self):
|
||||||
|
assert employer_match("", "") is False
|
||||||
|
|
||||||
|
def test_one_empty_no_match(self):
|
||||||
|
assert employer_match("Acme", "") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescScore:
|
||||||
|
def test_identical_descriptions(self):
|
||||||
|
desc = "We are looking for a Python developer with 5 years experience."
|
||||||
|
assert desc_score(desc, desc) == 100.0
|
||||||
|
|
||||||
|
def test_similar_descriptions_high(self):
|
||||||
|
a = "We are looking for a Python developer with 5 years experience."
|
||||||
|
b = "We are looking for a Python developer with 5 years experience in web."
|
||||||
|
assert desc_score(a, b) >= 80.0
|
||||||
|
|
||||||
|
def test_different_descriptions_low(self):
|
||||||
|
a = "We need a frontend developer skilled in React and CSS."
|
||||||
|
b = "Looking for a data scientist with Python and SQL expertise."
|
||||||
|
assert desc_score(a, b) < 50.0
|
||||||
|
|
||||||
|
def test_empty_desc(self):
|
||||||
|
assert desc_score("", "something") == 0.0
|
||||||
|
|
||||||
|
def test_truncation(self):
|
||||||
|
# Test that truncation to max_chars works.
|
||||||
|
long_a = "Python " * 1000
|
||||||
|
long_b = "Python " * 1000
|
||||||
|
score = desc_score(long_a, long_b, max_chars=100)
|
||||||
|
assert score == 100.0
|
||||||
Loading…
Reference in a new issue