mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
fix(rank): reject an unknown veto verdict instead of shortlisting the job (#640)
* fix(rank): reject an unknown veto verdict instead of shortlisting the job Step 5's veto is an exact match on "FAIL", so a scoring agent's "Fail" or any other off-contract location_verdict/language_gate value was stored as given and a deal-breaker job reached the shortlist. Report it as an errors row and leave the entry unwritten, as an unknown status already is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * changelog: place the #640 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
This commit is contained in:
3 files changed
+26
No files matched your search
@@ -432,6 +432,8 @@ per-file diff commands.
|
|||||||
|
|
||||||
- **`rank_state.py apply` rounds weighted scores exactly** (#588) - decimal half-points such as 31/55/51/96 now produce 60/Good Fit instead of 59/Moderate Fit from binary floating-point drift.
|
- **`rank_state.py apply` rounds weighted scores exactly** (#588) - decimal half-points such as 31/55/51/96 now produce 60/Good Fit instead of 59/Moderate Fit from binary floating-point drift.
|
||||||
|
|
||||||
|
- **`rank_state.py apply` rejects unknown gate verdicts** (#640) - invalid `location_verdict` and `language_gate` values now return an error without shortlisting or writing the entry.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a
|
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a
|
||||||
|
|||||||
@@ -859,6 +859,17 @@ class Unreachable(RankStateCase):
|
|||||||
self.assertEqual([r["key"] for r in out["ranked"]], ["b"], "valid results in the same batch still apply")
|
self.assertEqual([r["key"] for r in out["ranked"]], ["b"], "valid results in the same batch still apply")
|
||||||
self.assertEqual(self.read_state()["a"]["status"], "new")
|
self.assertEqual(self.read_state()["a"]["status"], "new")
|
||||||
|
|
||||||
|
def test_an_unknown_gate_verdict_is_an_error_not_a_shortlisted_job(self):
|
||||||
|
scores = {dimension: 90 for dimension in ("technical", "experience", "behavioral", "career")}
|
||||||
|
for field, value in (("location_verdict", "Fail"), ("language_gate", "fail"), ("location_verdict", "REJECT")):
|
||||||
|
with self.subTest(field=field, value=value):
|
||||||
|
self.write_state({"a": entry()})
|
||||||
|
result = {"key": "a", "status": "scored", "scores": scores, field: value}
|
||||||
|
out = self.run_tool("apply", "--results", self.results([result]), expect=1)
|
||||||
|
self.assertEqual(out["ranked"], [], "a deal-breaker spelled differently must not reach the shortlist")
|
||||||
|
self.assertIn(f"unknown {field} {value!r}", out["errors"][0]["error"])
|
||||||
|
self.assertEqual(self.read_state()["a"]["status"], "new", "a rejected result never half-writes an entry")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -65,6 +65,10 @@ ISO = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|||||||
# names exactly these, so the spec and the tool cannot drift apart.
|
# names exactly these, so the spec and the tool cannot drift apart.
|
||||||
AGENT_STATUSES = ("scored", "expired", "unreachable")
|
AGENT_STATUSES = ("scored", "expired", "unreachable")
|
||||||
|
|
||||||
|
# The values the two veto gates may take (rank.md Step 2). Step 5's veto is an
|
||||||
|
# exact match on "FAIL", so any other spelling would shortlist a deal-breaker.
|
||||||
|
GATE_VERDICTS = ("PASS", "FAIL", "FLAG")
|
||||||
|
|
||||||
# A posting that could not be fetched is `unreachable`, not `expired`, and is
|
# A posting that could not be fetched is `unreachable`, not `expired`, and is
|
||||||
# retried on later runs (#534). It gives up to `expired` only after this many
|
# retried on later runs (#534). It gives up to `expired` only after this many
|
||||||
# failed attempts with at least this long between the first and the last, so a
|
# failed attempts with at least this long between the first and the last, so a
|
||||||
@@ -395,6 +399,15 @@ def cmd_apply(args) -> int:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
errors.append({"key": key, "error": str(exc)})
|
errors.append({"key": key, "error": str(exc)})
|
||||||
continue
|
continue
|
||||||
|
bad_gate = next(
|
||||||
|
(f for f in ("location_verdict", "language_gate") if result.get(f) and result[f] not in GATE_VERDICTS),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if bad_gate:
|
||||||
|
errors.append(
|
||||||
|
{"key": key, "error": f"unknown {bad_gate} {result[bad_gate]!r}; expected one of {', '.join(GATE_VERDICTS)}"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# A missing deadline from the fresh fetch is not a correction to the
|
# A missing deadline from the fresh fetch is not a correction to the
|
||||||
# stored date. Scored keys are excluded from the later sweep, so apply
|
# stored date. Scored keys are excluded from the later sweep, so apply
|
||||||
|
|||||||
Reference in new issue
Block a user