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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qej5SBvhcbGXHwLqNWiskM
This commit is contained in:
1 parent
3519b2ffe7
commit
afd8174bbd
2 files changed
+206
-16
No files matched your search
+141
-16
@@ -58,10 +58,19 @@ API_FETCH_WORKERS = 8
|
|||||||
CH_LOCATION_KEYWORDS = [
|
CH_LOCATION_KEYWORDS = [
|
||||||
"switzerland", "zurich", "zürich", "basel", "bern", "geneva", "genf",
|
"switzerland", "zurich", "zürich", "basel", "bern", "geneva", "genf",
|
||||||
"lausanne", "zug", "rüschlikon", "stäfa", "schweiz", "suisse",
|
"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 = [
|
DK_LOCATION_KEYWORDS = [
|
||||||
"denmark", "copenhagen", "danmark", "københavn", "koebenhavn",
|
"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 = [
|
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"(?<![\w])" + re.escape(keyword) + r"(?![\w])", low):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def location_eligibility(loc_text, policy="default"):
|
def location_eligibility(loc_text, policy="default"):
|
||||||
"""Decide whether a posting's location is in-scope.
|
"""Decide whether a posting's location is in-scope.
|
||||||
|
|
||||||
@@ -1745,15 +1772,19 @@ def location_eligibility(loc_text, policy="default"):
|
|||||||
if not loc_text:
|
if not loc_text:
|
||||||
return False, False, False, False
|
return False, False, False, False
|
||||||
low = loc_text.lower()
|
low = loc_text.lower()
|
||||||
in_ch = any(k in low for k in CH_LOCATION_KEYWORDS)
|
in_ch = _place_match(low, CH_LOCATION_KEYWORDS)
|
||||||
in_dk = any(k in low for k in DK_LOCATION_KEYWORDS)
|
in_dk = _place_match(low, DK_LOCATION_KEYWORDS)
|
||||||
in_no = any(k in low for k in NO_LOCATION_KEYWORDS)
|
in_no = _place_match(low, NO_LOCATION_KEYWORDS)
|
||||||
is_us_only = any(p in low for p in US_ONLY_PATTERNS) and not in_ch
|
is_us_only = any(p in low for p in US_ONLY_PATTERNS) and not in_ch
|
||||||
has_remote = bool(re.search(r"\bremote\b", low)) or "work from anywhere" in low
|
has_remote = bool(re.search(r"\bremote\b", low)) or "work from anywhere" in low
|
||||||
europe_scope = any(k in low for k in EUROPE_SCOPE_KEYWORDS)
|
europe_scope = any(k in low for k in EUROPE_SCOPE_KEYWORDS)
|
||||||
global_scope = any(k in low for k in GLOBAL_SCOPE_KEYWORDS) and not is_us_only
|
global_scope = any(k in low for k in GLOBAL_SCOPE_KEYWORDS) and not is_us_only
|
||||||
# Pan-regional scope counts even without the word "remote" (e.g. QuantCo "Europe").
|
# Pan-regional scope counts even without the word "remote" (e.g. QuantCo "Europe"),
|
||||||
is_europe_or_global = (europe_scope or global_scope) and not is_us_only
|
# but NOT when the posting also says onsite or restricts residence to one country
|
||||||
|
# (F12: "Europe" beside "London / Onsite" used to read as remote-eligible).
|
||||||
|
restricted = bool(_ONSITE.search(low)) or (
|
||||||
|
bool(_COUNTRY_ONLY.search(low)) and not (in_ch or in_dk))
|
||||||
|
is_europe_or_global = (europe_scope or global_scope) and not is_us_only and not restricted
|
||||||
is_ch_remote = in_ch and has_remote
|
is_ch_remote = in_ch and has_remote
|
||||||
|
|
||||||
if policy == "norway_only":
|
if policy == "norway_only":
|
||||||
@@ -1899,11 +1930,20 @@ def _decision_key(url):
|
|||||||
if m:
|
if m:
|
||||||
return f"{host}#{m.group(1)}"
|
return f"{host}#{m.group(1)}"
|
||||||
|
|
||||||
# No known ID shape: fall back to the path with cosmetic variation stripped.
|
# No known ID shape: fall back to the path with cosmetic variation stripped. The QUERY
|
||||||
|
# is kept (F13): dropping it made /vacancy?job=1 and /vacancy?job=2 the same key, so one
|
||||||
|
# decision silently covered two different requisitions.
|
||||||
path = parts.path.rstrip("/")
|
path = parts.path.rstrip("/")
|
||||||
if path.endswith("/apply"):
|
if path.endswith("/apply"):
|
||||||
path = path[: -len("/apply")]
|
path = path[: -len("/apply")]
|
||||||
return f"{host}#{path.lower()}"
|
query = urllib.parse.parse_qs(parts.query)
|
||||||
|
# Only identifying parameters; tracking noise must not fragment the key.
|
||||||
|
keep = {k: v for k, v in query.items()
|
||||||
|
if k.lower() in {"job", "jobid", "id", "req", "reqid", "requisition", "gh_jid", "vacancy"}}
|
||||||
|
suffix = ""
|
||||||
|
if keep:
|
||||||
|
suffix = "?" + "&".join(f"{k}={sorted(v)[0]}" for k, v in sorted(keep.items()))
|
||||||
|
return f"{host}#{path.lower()}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
def load_decisions():
|
def load_decisions():
|
||||||
@@ -1958,6 +1998,36 @@ def find_decision_url(decisions, url):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _raw_decisions():
|
||||||
|
"""Raw decision log for the report path's slug fallback (cached per run)."""
|
||||||
|
return load_decisions()
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_decision(dec_index, decisions, url):
|
||||||
|
"""Resolve a decision for a report row, using the SAME identity rules as the write path.
|
||||||
|
|
||||||
|
The 2026-09-04 fix taught `find_decision_url` (used by --decide) that RUAG regenerates the
|
||||||
|
UUID on every repost, but the REPORT path kept doing a bare `_decision_key` lookup. Result:
|
||||||
|
a reposted RUAG requisition came back as undecided although the decision was in the log -
|
||||||
|
which is exactly what happened to `Lead-Architekt Network- und Richtfunk` on 2026-09-10,
|
||||||
|
days after 63 RUAG rule-outs had been written.
|
||||||
|
|
||||||
|
A slug match is reported as RELATED, not as the same requisition: a repost can differ
|
||||||
|
materially, so an old rule-out must not be applied to it automatically.
|
||||||
|
"""
|
||||||
|
hit = dec_index.get(_decision_key(url))
|
||||||
|
if hit is not None:
|
||||||
|
return hit
|
||||||
|
existing = find_decision_url(decisions, url)
|
||||||
|
if existing is None:
|
||||||
|
return None
|
||||||
|
related = dict(decisions[existing])
|
||||||
|
related["matched_by"] = "slug"
|
||||||
|
related["matched_url"] = existing
|
||||||
|
return related
|
||||||
|
|
||||||
|
|
||||||
def save_decisions(decisions):
|
def save_decisions(decisions):
|
||||||
_atomic_write_json(DECISIONS_FILE, decisions)
|
_atomic_write_json(DECISIONS_FILE, decisions)
|
||||||
|
|
||||||
@@ -2053,7 +2123,8 @@ def write_report(path, results, errors, new_only, include_weak, stats=None, tota
|
|||||||
|
|
||||||
if not include_weak and weak:
|
if not include_weak and weak:
|
||||||
lines.append(f"\n_Hiding {len(weak)} weak/noise roles (score < 2). Use --include-weak to show._")
|
lines.append(f"\n_Hiding {len(weak)} weak/noise roles (score < 2). Use --include-weak to show._")
|
||||||
n_decided = sum(1 for r in results if _decision_key(r["url"]) in decisions)
|
n_decided = sum(1 for r in results
|
||||||
|
if lookup_decision(decisions, _raw_decisions(), r["url"]) is not None)
|
||||||
if n_decided:
|
if n_decided:
|
||||||
shown = "hidden" if hide_decided else "tagged inline"
|
shown = "hidden" if hide_decided else "tagged inline"
|
||||||
lines.append(f"_{n_decided} role(s) already in the decision log ({shown}; "
|
lines.append(f"_{n_decided} role(s) already in the decision log ({shown}; "
|
||||||
@@ -2066,12 +2137,13 @@ def write_report(path, results, errors, new_only, include_weak, stats=None, tota
|
|||||||
|
|
||||||
for bucket_name, bucket in buckets:
|
for bucket_name, bucket in buckets:
|
||||||
shown = [r for r in bucket
|
shown = [r for r in bucket
|
||||||
if not (hide_decided and _decision_key(r["url"]) in decisions)]
|
if not (hide_decided and
|
||||||
|
lookup_decision(decisions, _raw_decisions(), r["url"]) is not None)]
|
||||||
if not shown:
|
if not shown:
|
||||||
continue
|
continue
|
||||||
lines.append(f"\n## {bucket_name} - {len(shown)} role(s)\n")
|
lines.append(f"\n## {bucket_name} - {len(shown)} role(s)\n")
|
||||||
for r in shown:
|
for r in shown:
|
||||||
d = decisions.get(_decision_key(r["url"]))
|
d = lookup_decision(decisions, _raw_decisions(), r["url"])
|
||||||
new_tag = " [NEW]" if r["is_new"] else ""
|
new_tag = " [NEW]" if r["is_new"] else ""
|
||||||
decided_tag = f" — 🗂 {d['decision'].upper()}" if d else ""
|
decided_tag = f" — 🗂 {d['decision'].upper()}" if d else ""
|
||||||
loc_tag = ("CH" if r["in_ch"] else
|
loc_tag = ("CH" if r["in_ch"] else
|
||||||
@@ -2086,6 +2158,12 @@ def write_report(path, results, errors, new_only, include_weak, stats=None, tota
|
|||||||
lines.append(f"- URL: {r['url']}")
|
lines.append(f"- URL: {r['url']}")
|
||||||
if d:
|
if d:
|
||||||
note = f" — {d['note']}" if d.get("note") else ""
|
note = f" — {d['note']}" if d.get("note") else ""
|
||||||
|
if d.get("matched_by") == "slug":
|
||||||
|
lines.append(
|
||||||
|
f"- 🗂 RELATED decision (same slug, **new requisition id** — a repost can "
|
||||||
|
f"differ, so re-read before reusing): **{d['decision']}**{note} "
|
||||||
|
f"({d.get('date','')}) · matched `{d.get('matched_url','')}`")
|
||||||
|
else:
|
||||||
lines.append(f"- 🗂 Decision: **{d['decision']}**{note} ({d.get('date','')})")
|
lines.append(f"- 🗂 Decision: **{d['decision']}**{note} ({d.get('date','')})")
|
||||||
if r["pos"]:
|
if r["pos"]:
|
||||||
lines.append(f"- Positive: {', '.join(r['pos'])}")
|
lines.append(f"- Positive: {', '.join(r['pos'])}")
|
||||||
@@ -2118,12 +2196,38 @@ def _fetch_company(cid, display, adapter, args):
|
|||||||
return cid, display, adapter, args, None, msg, time.perf_counter() - t0
|
return cid, display, adapter, args, None, msg, time.perf_counter() - t0
|
||||||
|
|
||||||
|
|
||||||
|
def _requisition_key(cid, job):
|
||||||
|
"""Stable per-requisition identity within one employer.
|
||||||
|
|
||||||
|
Prefers a board requisition id parsed out of the URL (the same patterns the decision log
|
||||||
|
uses), then the adapter's own id, then the URL. Employer-scoped so two boards that number
|
||||||
|
requisitions the same way can never collide.
|
||||||
|
"""
|
||||||
|
url = (job.get("url") or "").strip()
|
||||||
|
if url:
|
||||||
|
for host_pat, id_pat in _JOB_ID_PATTERNS:
|
||||||
|
try:
|
||||||
|
parts = urllib.parse.urlsplit(url)
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
host = (parts.netloc or "").lower()
|
||||||
|
if host_pat and not re.search(host_pat, host):
|
||||||
|
continue
|
||||||
|
match = re.search(id_pat, parts.path + (f"?{parts.query}" if parts.query else ""))
|
||||||
|
if match:
|
||||||
|
return f"{cid}#{match.group(1)}"
|
||||||
|
identifier = job.get("id")
|
||||||
|
return f"{cid}#{identifier}" if identifier else f"{cid}#{url or job.get('title', '')}"
|
||||||
|
|
||||||
|
|
||||||
def _process_company(cid, display, args, jobs, seen, today, last_scrape):
|
def _process_company(cid, display, args, jobs, seen, today, last_scrape):
|
||||||
"""Filter, score, and update seen for one company's job list.
|
"""Filter, score, and update seen for one company's job list.
|
||||||
|
|
||||||
Returns (results, stat_dict, warnings) where warnings is a list of (display, msg).
|
Returns (results, stat_dict, warnings) where warnings is a list of (display, msg).
|
||||||
"""
|
"""
|
||||||
warnings = []
|
warnings = []
|
||||||
|
req_seen: dict[str, dict] = {}
|
||||||
|
title_collisions: dict[str, int] = {}
|
||||||
scraped = len(jobs)
|
scraped = len(jobs)
|
||||||
prev = (last_scrape.get(cid) or {}).get("scraped", 0)
|
prev = (last_scrape.get(cid) or {}).get("scraped", 0)
|
||||||
if scraped == 0:
|
if scraped == 0:
|
||||||
@@ -2164,11 +2268,24 @@ def _process_company(cid, display, args, jobs, seen, today, last_scrape):
|
|||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
continue
|
continue
|
||||||
# Collapse the same role posted once per remote country (title differs only
|
# Requisition identity is the primary key (F11). Title alone is NOT identity: two
|
||||||
# by a "| Country | Remote" suffix) — dedupe on the title before the first "|".
|
# different reqs called "Senior Data Engineer" in Bern and Zurich used to collapse
|
||||||
|
# into one, and different teams hiring the same title vanished silently.
|
||||||
|
req_key = _requisition_key(cid, j)
|
||||||
|
if req_key in req_seen:
|
||||||
|
# Same requisition seen again - the case the old rule existed for, a role
|
||||||
|
# republished once per location. Merge the location instead of dropping it.
|
||||||
|
first = req_seen[req_key]
|
||||||
|
extra = (j.get("location") or "").strip()
|
||||||
|
if extra and extra.lower() not in first["location"].lower():
|
||||||
|
first["location"] = f"{first['location']} / {extra}"
|
||||||
|
continue
|
||||||
norm_title = re.sub(r"\s+", " ", (j.get("title") or "").split("|")[0]).strip().lower()
|
norm_title = re.sub(r"\s+", " ", (j.get("title") or "").split("|")[0]).strip().lower()
|
||||||
if norm_title in title_seen:
|
if norm_title in title_seen:
|
||||||
continue
|
# Same title, DIFFERENT requisition. Keep it and flag it; removing it here is
|
||||||
|
# what lost real roles.
|
||||||
|
title_collisions.setdefault(norm_title, 0)
|
||||||
|
title_collisions[norm_title] += 1
|
||||||
title_seen.add(norm_title)
|
title_seen.add(norm_title)
|
||||||
eligible += 1
|
eligible += 1
|
||||||
is_new = jid not in company_seen
|
is_new = jid not in company_seen
|
||||||
@@ -2180,7 +2297,7 @@ def _process_company(cid, display, args, jobs, seen, today, last_scrape):
|
|||||||
score = floor
|
score = floor
|
||||||
if score >= 2:
|
if score >= 2:
|
||||||
match += 1
|
match += 1
|
||||||
results.append({
|
row = {
|
||||||
"company": display, "company_id": cid,
|
"company": display, "company_id": cid,
|
||||||
"title": j["title"], "location": j["location"],
|
"title": j["title"], "location": j["location"],
|
||||||
"url": j["url"], "posted": j.get("posted", ""),
|
"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,
|
"score": score, "pos": pos, "neg": neg,
|
||||||
"in_ch": in_ch, "remote": is_remote, "relocation": is_relocation,
|
"in_ch": in_ch, "remote": is_remote, "relocation": is_relocation,
|
||||||
"is_new": is_new,
|
"is_new": is_new,
|
||||||
})
|
}
|
||||||
|
results.append(row)
|
||||||
|
req_seen[req_key] = row
|
||||||
if is_new:
|
if is_new:
|
||||||
company_seen[jid] = {"title": j["title"], "first_seen": today}
|
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 = {
|
stat = {
|
||||||
"company": display, "scraped": scraped, "eligible": eligible,
|
"company": display, "scraped": scraped, "eligible": eligible,
|
||||||
"match": match, "newest": newest, "error": False,
|
"match": match, "newest": newest, "error": False,
|
||||||
@@ -2338,7 +2463,7 @@ def main():
|
|||||||
"company": r["company"], "title": r["title"], "location": r["location"],
|
"company": r["company"], "title": r["title"], "location": r["location"],
|
||||||
"url": r["url"], "posted": r["posted"], "deadline": r.get("deadline", ""),
|
"url": r["url"], "posted": r["posted"], "deadline": r.get("deadline", ""),
|
||||||
"is_new": r["is_new"],
|
"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"]},
|
"keyword_signal": {"score": r["score"], "pos": r["pos"], "neg": r["neg"]},
|
||||||
"loc_flags": {
|
"loc_flags": {
|
||||||
"in_ch": r["in_ch"], "remote": r["remote"], "relocation": r["relocation"],
|
"in_ch": r["in_ch"], "remote": r["remote"], "relocation": r["relocation"],
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user