From f9ef82f16900d81b99255cb63c3d675df21c7767 Mon Sep 17 00:00:00 2001 From: Ayobami Adegoke Date: Fri, 2 Oct 2026 19:00:16 +0100 Subject: [PATCH] fix(rank): sweep deadlines when no candidates need scoring (#494) * fix(rank): sweep deadlines when no candidates need scoring * changelog: record the /rank empty-candidate sweep under [Unreleased] / Fixed --- .claude/commands/rank.md | 10 +++++++++- CHANGELOG.md | 16 ++++++++++++++++ tests/test_rank_command.py | 15 +++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.claude/commands/rank.md b/.claude/commands/rank.md index d5676718..59ff112a 100644 --- a/.claude/commands/rank.md +++ b/.claude/commands/rank.md @@ -32,7 +32,15 @@ python3 tools/rank_state.py candidates --limit 10 # add --all / --focus It applies the status filter (`new`, or any status with `--all`), the tracker exclusion (any company+role already in `job_search_tracker.csv` is out of scope regardless of flags - it has been applied to or consciously tracked), the focus filter, and `--limit`, then prints one compact object per candidate (`key`, `title`, `company`, `url`, `portal`, `deadline`, `posted_date`) plus the counts: `eligible`, `deferred` (eligible beyond the limit, kept at their current status so a later run continues the backlog), `excluded_by_tracker`. -If it reports no candidates, say so ("Nothing new to rank - run /scrape to find fresh postings") and stop. If it exits with "not found", tell the user to run `/scrape` first and stop. +If it exits with "not found", tell the user to run `/scrape` first and stop. + +If it reports no candidates, there is nothing to score, but already-ranked jobs still need Step 3's rule 6 expiry sweep. Run: + +```bash +python3 tools/rank_state.py sweep --write +``` + +Skip profile loading and Steps 2-4 on this path: no jobs need fetching or scoring. Present a sweep-only Step 5 summary: say "Nothing new to rank", report `swept` and `newly_expired`, list `closing_soon` with deadlines and posting URLs, and report any `unparseable_deadlines` with their portals. Apply rule 6's same reporting and defensive-date rules. Then suggest `/scrape` for fresh postings and stop. An empty candidate batch, including one caused by a focus filter or tracker exclusion, never skips this sweep. Then read the scoring framework and profile **once**: - `.claude/skills/job-application-assistant/04-job-evaluation.md` diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c45ff1..e3603a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,22 @@ per-file diff commands. ### Fixed +- **`/rank` still sweeps deadlines when there is nothing new to score** (`.claude/commands/rank.md` + Step 1, `tests/test_rank_command.py`) - when `rank_state.py candidates` reported no eligible + jobs, Step 1 said "Nothing new to rank" and stopped before Step 3's rule 6 expiry sweep ever + ran. Once a backlog has been ranked, that is the path every later `/rank` takes, so + past-deadline jobs stayed `ranked` and approaching-deadline reminders were never shown. + Reproduced on the real CLI with four `ranked` entries (deadlines 2026-09-22, 2026-09-28, + null, `ASAP`): `candidates --today 2026-09-25` returns `eligible: 0` and master stops there, + while `sweep --write` on the same state expires the 09-22 entry, lists 09-28 under + `closing_soon` and `ASAP` under `unparseable_deadlines`, and leaves the null one alone. The + empty-candidate branch of Step 1 now runs `python3 tools/rank_state.py sweep --write`, skips + profile loading and Steps 2-4 (nothing to fetch or score; the tracker is untouched), and + presents a sweep-only Step 5 summary - `swept`, `newly_expired`, `closing_soon` with deadlines + and URLs, `unparseable_deadlines` with portals - before suggesting `/scrape`. An empty batch + caused by a focus filter or tracker exclusion takes the same path. Pinned by + `test_step1_empty_candidates_still_sweeps_and_reports`, which fails against master's `rank.md`. + - **`robots_check` no longer reads a leading BOM or an undecodable rule as permission** (`tools/robots_check.py`, `tests/test_robots_check.py`) - two decoding edge cases failed open, both flagged as follow-ups in #506. A robots.txt saved with a UTF-8 byte-order mark diff --git a/tests/test_rank_command.py b/tests/test_rank_command.py index bd6bfd39..267ce8bc 100644 --- a/tests/test_rank_command.py +++ b/tests/test_rank_command.py @@ -43,6 +43,21 @@ def _sections(text: str) -> dict[str, str]: class RankCommandSpec(unittest.TestCase): + def test_step1_empty_candidates_still_sweeps_and_reports(self): + """No new jobs must not bypass expiry maintenance of ranked jobs.""" + step1 = _sections(COMMAND.read_text(encoding="utf-8"))["Step 1: Load State"] + empty_branch = step1.partition("If it reports no candidates")[2].partition("Then read")[0] + self.assertIn( + "python3 tools/rank_state.py sweep --write", + empty_branch, + "an empty candidate batch must still persist rule 6's expiry sweep", + ) + self.assertNotIn("--exclude", empty_branch, "no jobs were re-scored on this path") + for field in ("swept", "newly_expired", "closing_soon", "unparseable_deadlines"): + self.assertIn(field, empty_branch, f"the empty-run report must consume {field}") + self.assertIn("Skip", empty_branch) + self.assertIn("Steps 2-4", empty_branch, "an empty run must not fetch or score jobs") + def test_command_file_exists_with_lint_compliant_header(self): self.assertTrue(COMMAND.is_file(), "command spec missing") first_line = COMMAND.read_text(encoding="utf-8").splitlines()[0]