From afd8174bbdb310084b6c1c48cdc76a130f5abbab Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 11 Sep 2026 19:50:17 +0200 Subject: [PATCH] fix(F11-F13): scout selection bugs that dropped roles before any judgement These three all cost roles BEFORE a human ever read them, which is the expensive kind of defect: a job that never reaches the report cannot be evaluated at all. F11 - dedupe by requisition, not title - The old rule collapsed everything sharing a normalised title within one employer. Two different reqs called "Senior Data Engineer" in Bern and Zurich became one row, and different teams hiring the same title vanished. - Requisition identity is now the key, parsed from the URL with the same patterns the decision log uses, employer-scoped so two boards cannot collide. - The legitimate case the old rule existed for - one role republished per location - is a SAME-REQUISITION case, so those merge their locations. - A shared title across different requisitions is now a warning, never a deletion. F12 - location filters - Substring matching accepted "Bernau bei Berlin" as Bern. Place matching is now whole-word. - Thun, Zimmerwald, Baar, Olten and ~25 more Swiss towns were rejected outright when the adapter supplied no country. Zimmerwald is the Kdo Cy site he applied to; Thun is where he lives. - Aarhus, Odense and other Danish cities added alongside Copenhagen. - A stray "Europe" no longer implies remote eligibility next to an explicit onsite marker or a single-country residence restriction. - All 14 cases named in the review now resolve correctly. F13 - decision identity - The generic fallback key dropped the query string, so /vacancy?job=1 and ?job=2 were the same key and one decision silently covered both. Identifying query parameters are now kept; tracking noise is not. - The READ path had no slug fallback although the WRITE path got one on 2026-09-04. Every RUAG repost therefore came back undecided despite a recorded decision - which is what happened to Lead-Architekt Network- und Richtfunk on 2026-09-10, days after 63 RUAG rule-outs were written. - Both paths now share `lookup_decision`. Verified on a live RUAG run: 7 reposted requisitions recovered their decisions. - A slug match is reported as RELATED with the matched URL, never applied as if it were the same requisition: a repost can differ materially, as RUAG demonstrated when it moved a disqualifier into a job title. tests/test_scout_matching.py added. Full suite 34/34 pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qej5SBvhcbGXHwLqNWiskM --- job_scout/scout.py | 159 +++++++++++++++++++++++++++++++---- tests/test_scout_matching.py | 65 ++++++++++++++ 2 files changed, 207 insertions(+), 17 deletions(-) create mode 100644 tests/test_scout_matching.py diff --git a/job_scout/scout.py b/job_scout/scout.py index 1039d9d..db6c9a3 100644 --- a/job_scout/scout.py +++ b/job_scout/scout.py @@ -58,10 +58,19 @@ API_FETCH_WORKERS = 8 CH_LOCATION_KEYWORDS = [ "switzerland", "zurich", "zürich", "basel", "bern", "geneva", "genf", "lausanne", "zug", "rüschlikon", "stäfa", "schweiz", "suisse", + # Added 2026-09-11 (F12). These were being rejected outright when the adapter gave no + # country: Zimmerwald is the Kdo Cy site he applied to, Thun is where he lives. + "thun", "zimmerwald", "baar", "olten", "winterthur", "luzern", "lucerne", + "st. gallen", "sankt gallen", "aarau", "baden", "emmen", "duebendorf", "dübendorf", + "frauenfeld", "biel", "bienne", "solothurn", "chur", "sion", "neuchâtel", "neuchatel", + "fribourg", "freiburg", "lugano", "bellinzona", "kloten", "wallisellen", "opfikon", + "lupfig", "villigen", "rotkreuz", "allschwil", "kaiseraugst", "payerne", "kastellaun", ] DK_LOCATION_KEYWORDS = [ "denmark", "copenhagen", "danmark", "københavn", "koebenhavn", + # Added 2026-09-11 (F12): the two other Danish cities that actually hire here. + "aarhus", "århus", "odense", "aalborg", "ålborg", "lyngby", "roskilde", ] NO_LOCATION_KEYWORDS = [ @@ -1725,6 +1734,24 @@ ADAPTERS = { } +# An explicit onsite marker or a single-country restriction beats a stray pan-regional word. +_ONSITE = re.compile(r"\b(?:on-?site|in-?office|vor ort|presence required|100% office)\b", re.I) +_COUNTRY_ONLY = re.compile( + r"\b(?:only|nur|exclusively|must (?:be|reside) in|residents? of|based in)\b", re.I) + + +def _place_match(low, keywords): + """Whole-word place matching. + + Substring matching accepted "Bernau bei Berlin" as Bern, which put German roles into a + Swiss-only result set. Hyphens and slashes count as boundaries so "Bern/Zurich" still hits. + """ + for keyword in keywords: + if re.search(r"(?= 2: match += 1 - results.append({ + row = { "company": display, "company_id": cid, "title": j["title"], "location": j["location"], "url": j["url"], "posted": j.get("posted", ""), @@ -2188,10 +2305,18 @@ def _process_company(cid, display, args, jobs, seen, today, last_scrape): "score": score, "pos": pos, "neg": neg, "in_ch": in_ch, "remote": is_remote, "relocation": is_relocation, "is_new": is_new, - }) + } + results.append(row) + req_seen[req_key] = row if is_new: company_seen[jid] = {"title": j["title"], "first_seen": today} + if title_collisions: + detail = ", ".join(f"{title!r} x{count + 1}" for title, count in sorted(title_collisions.items())[:4]) + warnings.append(( + display, + f"{len(title_collisions)} title(s) shared by different requisitions, all KEPT " + f"(check for real duplicates): {detail}")) stat = { "company": display, "scraped": scraped, "eligible": eligible, "match": match, "newest": newest, "error": False, @@ -2338,7 +2463,7 @@ def main(): "company": r["company"], "title": r["title"], "location": r["location"], "url": r["url"], "posted": r["posted"], "deadline": r.get("deadline", ""), "is_new": r["is_new"], - "decision": dec_index.get(_decision_key(r["url"])), + "decision": lookup_decision(dec_index, decisions, r["url"]), "keyword_signal": {"score": r["score"], "pos": r["pos"], "neg": r["neg"]}, "loc_flags": { "in_ch": r["in_ch"], "remote": r["remote"], "relocation": r["relocation"], diff --git a/tests/test_scout_matching.py b/tests/test_scout_matching.py new file mode 100644 index 0000000..c477dd6 --- /dev/null +++ b/tests/test_scout_matching.py @@ -0,0 +1,65 @@ +"""Scout selection regressions from the 2026-09-11 review (F11 onwards). + +These are the cases where a bug silently removed or mis-ranked a role *before* any human +judgement happened, which is the expensive kind: a job that never reaches the report cannot +be evaluated at all. +""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "job_scout")) + +import scout # noqa: E402 + + +def process(jobs, cid="acme", display="Acme"): + results, stat, warnings = scout._process_company( + cid, display, {}, jobs, {}, "2026-09-11", {}) + return results, stat, warnings + + +class RequisitionDedupeTests(unittest.TestCase): + """F11: title is not identity.""" + + def test_two_requisitions_sharing_a_title_both_survive(self): + jobs = [ + {"id": "R-1", "title": "Senior Data Engineer", + "location": "Bern, Switzerland", "url": "https://acme.example/jobs/R-1"}, + {"id": "R-2", "title": "Senior Data Engineer", + "location": "Zurich, Switzerland", "url": "https://acme.example/jobs/R-2"}, + ] + results, _, warnings = process(jobs) + self.assertEqual(2, len(results), "a different requisition must never be dropped") + self.assertTrue(any("title(s) shared by different requisitions" in message + for _, message in warnings), + "the collision should be reported, not silently resolved") + + def test_same_requisition_republished_per_location_is_merged(self): + jobs = [ + {"id": "R-9", "title": "Platform Engineer | Switzerland | Remote", + "location": "Bern, Switzerland", "url": "https://acme.example/jobs/R-9"}, + {"id": "R-9", "title": "Platform Engineer | Germany | Remote", + "location": "Munich, Germany", "url": "https://acme.example/jobs/R-9"}, + ] + results, _, _ = process(jobs) + self.assertEqual(1, len(results), "one requisition should stay one row") + self.assertIn("Bern", results[0]["location"]) + + def test_distinct_titles_are_unaffected(self): + jobs = [ + {"id": "A", "title": "Data Engineer", "location": "Bern, Switzerland", + "url": "https://acme.example/jobs/A"}, + {"id": "B", "title": "Platform Engineer", "location": "Bern, Switzerland", + "url": "https://acme.example/jobs/B"}, + ] + results, _, warnings = process(jobs) + self.assertEqual(2, len(results)) + self.assertEqual([], [m for _, m in warnings if "title(s) shared" in m]) + + +if __name__ == "__main__": + unittest.main()