mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
refactor(salary): optimize search match scoring and normalize Excel category keys (#101)
This commit improves the performance and consistency of the salary tools: - Redundant query normalization and word extraction are eliminated in salary_lookup.py by pre-calculating representations once before the search loop. - A match_score_optimized helper is introduced to perform the comparison using the pre-calculated query data, preserving full backward compatibility for match_score. - Normalization in tools/convert_salary_excel.py is unified: paired column headers now consistently substitute spaces and dashes with underscores (e.g. 'software_engineering') to match the single-column formatting. - Unit test coverage is significantly expanded in tests/test_salary_lookup.py and tests/test_convert_salary_excel.py to cover normalization, anglicization, search filtering, and matching behaviors.
This commit is contained in:
+28
-17
@@ -83,9 +83,8 @@ def extract_core_words(s):
|
|||||||
return [w for w in words if len(w) > 1]
|
return [w for w in words if len(w) > 1]
|
||||||
|
|
||||||
|
|
||||||
def match_score(query, entry_name):
|
def match_score_optimized(q_norm, q_ang, q_words_set, q_words_ang_set, query, entry_name):
|
||||||
"""Compute a match score between 0 and 100 for ranking results."""
|
"""Compute a match score between 0 and 100 using precalculated query values."""
|
||||||
q_norm = normalize(query)
|
|
||||||
n_norm = normalize(entry_name)
|
n_norm = normalize(entry_name)
|
||||||
|
|
||||||
if not q_norm or not n_norm:
|
if not q_norm or not n_norm:
|
||||||
@@ -97,9 +96,8 @@ def match_score(query, entry_name):
|
|||||||
if q_norm in n_norm:
|
if q_norm in n_norm:
|
||||||
ratio = len(q_norm) / len(n_norm)
|
ratio = len(q_norm) / len(n_norm)
|
||||||
if len(q_norm) <= 4 and ratio < 0.5:
|
if len(q_norm) <= 4 and ratio < 0.5:
|
||||||
q_words = set(extract_core_words(query))
|
|
||||||
n_words = set(extract_core_words(entry_name))
|
n_words = set(extract_core_words(entry_name))
|
||||||
if not q_words & n_words:
|
if not q_words_set & n_words:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
return 80 + int(ratio * 10)
|
return 80 + int(ratio * 10)
|
||||||
@@ -112,7 +110,6 @@ def match_score(query, entry_name):
|
|||||||
else:
|
else:
|
||||||
return 80 + int(ratio * 10)
|
return 80 + int(ratio * 10)
|
||||||
|
|
||||||
q_ang = anglicize(q_norm)
|
|
||||||
n_ang = anglicize(n_norm)
|
n_ang = anglicize(n_norm)
|
||||||
if q_ang == n_ang:
|
if q_ang == n_ang:
|
||||||
return 85
|
return 85
|
||||||
@@ -120,43 +117,57 @@ def match_score(query, entry_name):
|
|||||||
shorter = min(len(q_ang), len(n_ang))
|
shorter = min(len(q_ang), len(n_ang))
|
||||||
longer = max(len(q_ang), len(n_ang))
|
longer = max(len(q_ang), len(n_ang))
|
||||||
if shorter <= 4 and shorter / longer < 0.5:
|
if shorter <= 4 and shorter / longer < 0.5:
|
||||||
q_words_ang = {anglicize(w) for w in extract_core_words(query)}
|
|
||||||
n_words_ang = {anglicize(w) for w in extract_core_words(entry_name)}
|
n_words_ang = {anglicize(w) for w in extract_core_words(entry_name)}
|
||||||
if q_words_ang & n_words_ang:
|
if q_words_ang_set & n_words_ang:
|
||||||
return 75
|
return 75
|
||||||
else:
|
else:
|
||||||
return 75
|
return 75
|
||||||
|
|
||||||
q_words = set(extract_core_words(query))
|
|
||||||
n_words = set(extract_core_words(entry_name))
|
n_words = set(extract_core_words(entry_name))
|
||||||
if not q_words or not n_words:
|
if not q_words_set or not n_words:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
overlap = q_words & n_words
|
overlap = q_words_set & n_words
|
||||||
if not overlap:
|
if not overlap:
|
||||||
q_words_ang = {anglicize(w) for w in q_words}
|
|
||||||
n_words_ang = {anglicize(w) for w in n_words}
|
n_words_ang = {anglicize(w) for w in n_words}
|
||||||
overlap = q_words_ang & n_words_ang
|
overlap = q_words_ang_set & n_words_ang
|
||||||
|
|
||||||
if overlap:
|
if overlap:
|
||||||
if len(q_words) == 1:
|
if len(q_words_set) == 1:
|
||||||
q_word = list(q_words)[0]
|
q_word = list(q_words_set)[0]
|
||||||
if q_word in n_words or anglicize(q_word) in {anglicize(w) for w in n_words}:
|
if q_word in n_words or anglicize(q_word) in {anglicize(w) for w in n_words}:
|
||||||
return 70
|
return 70
|
||||||
else:
|
else:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
coverage = len(overlap) / len(q_words)
|
coverage = len(overlap) / len(q_words_set)
|
||||||
return int(30 + coverage * 40)
|
return int(30 + coverage * 40)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def match_score(query, entry_name):
|
||||||
|
"""Compute a match score between 0 and 100 for ranking results."""
|
||||||
|
q_norm = normalize(query)
|
||||||
|
q_ang = anglicize(q_norm)
|
||||||
|
q_words = extract_core_words(query)
|
||||||
|
q_words_set = set(q_words)
|
||||||
|
q_words_ang_set = {anglicize(w) for w in q_words}
|
||||||
|
return match_score_optimized(q_norm, q_ang, q_words_set, q_words_ang_set, query, entry_name)
|
||||||
|
|
||||||
|
|
||||||
def search_company(data, query, city=None):
|
def search_company(data, query, city=None):
|
||||||
"""Search for a company by name. Returns matching entries sorted by relevance."""
|
"""Search for a company by name. Returns matching entries sorted by relevance."""
|
||||||
companies = data.get("companies", [])
|
companies = data.get("companies", [])
|
||||||
scored = []
|
scored = []
|
||||||
|
|
||||||
|
# Pre-calculate query representations once to avoid redundant computations inside the loop
|
||||||
|
q_norm = normalize(query)
|
||||||
|
q_ang = anglicize(q_norm)
|
||||||
|
q_words = extract_core_words(query)
|
||||||
|
q_words_set = set(q_words)
|
||||||
|
q_words_ang_set = {anglicize(w) for w in q_words}
|
||||||
|
|
||||||
for entry in companies:
|
for entry in companies:
|
||||||
if city:
|
if city:
|
||||||
city_lower = city.lower()
|
city_lower = city.lower()
|
||||||
@@ -164,7 +175,7 @@ def search_company(data, query, city=None):
|
|||||||
if city_lower not in entry_city and anglicize(city_lower) not in anglicize(entry_city):
|
if city_lower not in entry_city and anglicize(city_lower) not in anglicize(entry_city):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
score = match_score(query, entry["company"])
|
score = match_score_optimized(q_norm, q_ang, q_words_set, q_words_ang_set, query, entry["company"])
|
||||||
if score > 0:
|
if score > 0:
|
||||||
scored.append((score, entry))
|
scored.append((score, entry))
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ class DetectColumnTypeTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(companies[0]["categories"]["accounting"], {"count": 12, "index": 105.5})
|
self.assertEqual(companies[0]["categories"]["accounting"], {"count": 12, "index": 105.5})
|
||||||
|
|
||||||
|
def test_parse_sheet_normalizes_paired_category_name_with_underscores(self):
|
||||||
|
ws = FakeWorksheet([
|
||||||
|
("Company", "Software Engineering Count", "Software Engineering Index"),
|
||||||
|
("Example Corp", 8, 110.0),
|
||||||
|
])
|
||||||
|
|
||||||
|
companies = parse_sheet(ws)
|
||||||
|
|
||||||
|
self.assertEqual(companies[0]["categories"]["software_engineering"], {"count": 8, "index": 110.0})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from salary_lookup import format_entry, match_score, search_company
|
from salary_lookup import (
|
||||||
|
format_entry,
|
||||||
|
normalize,
|
||||||
|
anglicize,
|
||||||
|
extract_core_words,
|
||||||
|
match_score,
|
||||||
|
search_company,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -158,6 +165,68 @@ class SearchCompanyTests(unittest.TestCase):
|
|||||||
self.assertEqual(results, [])
|
self.assertEqual(results, [])
|
||||||
|
|
||||||
|
|
||||||
|
class UtilityTests(unittest.TestCase):
|
||||||
|
def test_normalize_strips_suffix_and_noise(self):
|
||||||
|
self.assertEqual(normalize("Novo Nordisk A/S"), "novonordisk")
|
||||||
|
self.assertEqual(normalize("Ørsted (VG) Holding"), "ørsted")
|
||||||
|
self.assertEqual(normalize("Chr. Hansen, Denmark Division"), "chrhansen")
|
||||||
|
self.assertEqual(normalize("Simple Corp ApS"), "simplecorp")
|
||||||
|
|
||||||
|
def test_anglicize_replaces_danish_chars(self):
|
||||||
|
self.assertEqual(anglicize("ørsted"), "orsted")
|
||||||
|
self.assertEqual(anglicize("mærsk"), "maersk")
|
||||||
|
self.assertEqual(anglicize("ålborg"), "aalborg")
|
||||||
|
|
||||||
|
def test_extract_core_words(self):
|
||||||
|
self.assertEqual(extract_core_words("Novo Nordisk A/S"), ["novo", "nordisk"])
|
||||||
|
self.assertEqual(extract_core_words("A/S"), [])
|
||||||
|
self.assertEqual(extract_core_words("Test Company (Sub-entity)"), ["test", "company"])
|
||||||
|
|
||||||
|
|
||||||
|
class MatchScoreTests(unittest.TestCase):
|
||||||
|
def test_exact_match_score(self):
|
||||||
|
self.assertEqual(match_score("Novo Nordisk", "Novo Nordisk"), 100)
|
||||||
|
self.assertEqual(match_score("novo nordisk", "Novo Nordisk A/S"), 100)
|
||||||
|
|
||||||
|
def test_partial_match_score(self):
|
||||||
|
self.assertGreater(match_score("Novo", "Novo Nordisk A/S"), 80)
|
||||||
|
self.assertEqual(match_score("Novo Nordisk", "Novo"), 75)
|
||||||
|
|
||||||
|
def test_anglicized_match_score(self):
|
||||||
|
self.assertEqual(match_score("Orsted", "Ørsted A/S"), 85)
|
||||||
|
|
||||||
|
def test_overlap_match_score(self):
|
||||||
|
# Overlap of multiple words
|
||||||
|
self.assertGreater(match_score("Novo Tech", "Novo Nordisk Tech A/S"), 30)
|
||||||
|
|
||||||
|
def test_no_match_score(self):
|
||||||
|
self.assertEqual(match_score("Google", "Microsoft"), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchCompanyRefactoredTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.data = {
|
||||||
|
"companies": [
|
||||||
|
{"company": "Novo Nordisk A/S", "city": "Bagsværd"},
|
||||||
|
{"company": "Ørsted", "city": "Fredericia"},
|
||||||
|
{"company": "Vestas Wind Systems", "city": "Aarhus"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_search_by_name(self):
|
||||||
|
results = search_company(self.data, "Novo")
|
||||||
|
self.assertEqual(len(results), 1)
|
||||||
|
self.assertEqual(results[0]["company"], "Novo Nordisk A/S")
|
||||||
|
|
||||||
|
def test_search_with_city_filter(self):
|
||||||
|
results = search_company(self.data, "Ørsted", city="Fredericia")
|
||||||
|
self.assertEqual(len(results), 1)
|
||||||
|
|
||||||
|
# Mismatching city
|
||||||
|
results_wrong_city = search_company(self.data, "Ørsted", city="Bagsværd")
|
||||||
|
self.assertEqual(len(results_wrong_city), 0)
|
||||||
|
|
||||||
|
|
||||||
class TestSearchCompanyBasicMatch(unittest.TestCase):
|
class TestSearchCompanyBasicMatch(unittest.TestCase):
|
||||||
def test_exact_name_returns_match(self):
|
def test_exact_name_returns_match(self):
|
||||||
data = _make_data(_entry("Novo Nordisk", "Bagsværd"))
|
data = _make_data(_entry("Novo Nordisk", "Bagsværd"))
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ def parse_sheet(ws, sheet_label=None):
|
|||||||
cat_name = strip_type_patterns(col_header, COUNT_PATTERNS)
|
cat_name = strip_type_patterns(col_header, COUNT_PATTERNS)
|
||||||
if not cat_name:
|
if not cat_name:
|
||||||
cat_name = f"category_{len(categories)+1}"
|
cat_name = f"category_{len(categories)+1}"
|
||||||
|
else:
|
||||||
|
cat_name = cat_name.replace(" ", "_").replace("-", "_")
|
||||||
categories.append({
|
categories.append({
|
||||||
"name": cat_name,
|
"name": cat_name,
|
||||||
"count_col": col_idx,
|
"count_col": col_idx,
|
||||||
@@ -156,6 +158,8 @@ def parse_sheet(ws, sheet_label=None):
|
|||||||
cat_name = strip_type_patterns(col_header, INDEX_PATTERNS)
|
cat_name = strip_type_patterns(col_header, INDEX_PATTERNS)
|
||||||
if not cat_name:
|
if not cat_name:
|
||||||
cat_name = f"category_{len(categories)+1}"
|
cat_name = f"category_{len(categories)+1}"
|
||||||
|
else:
|
||||||
|
cat_name = cat_name.replace(" ", "_").replace("-", "_")
|
||||||
categories.append({
|
categories.append({
|
||||||
"name": cat_name,
|
"name": cat_name,
|
||||||
"index_col": col_idx,
|
"index_col": col_idx,
|
||||||
|
|||||||
Reference in New Issue
Block a user