mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
Step 3 gains rule 7: a posting whose stored posted_date is more than 30 days old at rank time carries a visible staleness marker with its age spelled out alongside the score - FLAG treatment like location and language, never an exclusion. No posted_date or null means no flag and no guess (never inferred from first_seen), and rule 6's defensive-parse rule applies wherever the stored value is compared. Age is re-derived each run and never persisted. Four new spec pins in test_rank_command.py, each verified to fail against the rule-less spec.
This commit is contained in:
@@ -76,6 +76,22 @@ Back in the main context, for each scored job:
|
||||
5. **Deadline urgency:** a deadline within 7 days gets a 🔥 marker and wins ties. A deadline that has already passed moves the job to `expired`. Take the deadline from the scoring agent's Step 2 JSON for a job scored in this run, and from the stored `deadline` in `seen_jobs.json` for one that already carries it - a stored value costs no fetch, so urgency is re-derived on every run without re-reading the posting. When both exist and disagree, the freshly scored value wins and replaces the stored one. A stored value that does not parse as `YYYY-MM-DD` is skipped for urgency as well - rule 6's defensive-parse rule applies wherever a stored deadline is compared.
|
||||
6. **Expiry sweep over already-ranked entries.** Before presenting, check the stored `deadline` of every `ranked` entry this run did not re-score. Any whose deadline has passed becomes `expired`; any within 7 days is listed under a short **Closing soon** heading in Step 5 with its 🔥 marker. This needs no fetch and no agent - it is a date comparison against values already on disk, and it is what finally enforces `/scrape`'s "only open positions" rule beyond the moment of fetching. **An entry with no stored `deadline` is left alone, never guessed at** - most entries predate the column, and inferring a deadline from `first_seen` would retire jobs on a date nobody set. **Parse stored deadlines defensively:** a stored value that is not a `YYYY-MM-DD` date is treated exactly like an absent one - left alone, never compared, never guessed at - and reported once in the Step 5 summary with its portal, so the bad value gets traced to its source instead of silently steering the sweep (portals have shipped `"ASAP"`, `DD.MM.YYYY`, and free-text deadline shapes into stored data). `--all` re-scores entries of any status including `expired`, so a job the sweep retired can still be revived by a later `--all` that re-fetches it and finds the posting live: the sweep is reversible, which is what makes an automated status change acceptable here at all.
|
||||
|
||||
7. **Staleness flag:** a job whose stored `posted_date` is more than **30 days** old at
|
||||
rank time stays in the ranking but carries a visible ⚠ marker with its age spelled out
|
||||
alongside the score (e.g. "⚠ posted 2024-05-13, 27 months ago") - same treatment as a
|
||||
location or language FLAG, for the user to judge. Age is a signal, never a veto: the
|
||||
posting that motivated this rule was 27 months old *and still live*, so excluding on
|
||||
age would wrongly bury real openings - and a stale posting with a future stored
|
||||
`deadline` is still open by the stronger signal, so the flag notes the deadline too
|
||||
rather than contradicting it. This costs no fetch: `posted_date` is already on disk
|
||||
(written by `/scrape` Step 4), and age is re-derived on every run, never persisted.
|
||||
**An entry with no `posted_date` (or `null`) gets no flag and no guess** - entries
|
||||
predating the field simply lack the signal, and inferring age from `first_seen` would
|
||||
flag jobs on a date nobody posted. Rule 6's defensive-parse rule applies wherever a
|
||||
stored `posted_date` is compared: a value that does not parse as `YYYY-MM-DD` is
|
||||
treated exactly like an absent one and reported once in the Step 5 summary with its
|
||||
portal.
|
||||
|
||||
Sort by overall score (descending), urgency as tiebreaker.
|
||||
|
||||
---
|
||||
|
||||
@@ -13,6 +13,21 @@ per-file diff commands.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **`/rank` now consumes the `posted_date` #391 persists** (#390, the deferred second
|
||||
half) - Step 3 gains a staleness flag: a posting whose stored `posted_date` is more
|
||||
than 30 days old at rank time carries a visible ⚠ marker with its age spelled out
|
||||
alongside the score ("⚠ posted 2024-05-13, 27 months ago"), the same FLAG treatment as
|
||||
location and language - in the ranking, for the user to judge, never an exclusion (the
|
||||
#390 posting was 27 months old *and still live*; age is a signal, not a veto, and a
|
||||
future stored `deadline` outranks it). Costs no fetch: age is re-derived each run from
|
||||
the stored value and never persisted. Boundary rules carried over verbatim from the
|
||||
schema and rule 6: no `posted_date` or `null` means no flag and no guess (never
|
||||
inferred from `first_seen`), and unparseable values are treated as absent and reported
|
||||
once with their portal. Pinned by four new cases in `test_rank_command.py`, each
|
||||
verified to fail against the rule-less spec.
|
||||
|
||||
### Security
|
||||
|
||||
- **`settings.json` no longer pre-approves `bun run` on arbitrary files** (#396) - the
|
||||
|
||||
@@ -419,5 +419,70 @@ class RankCommandSpec(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 0, f"lint_skills.py failed:\n{result.stdout}{result.stderr}")
|
||||
|
||||
|
||||
class PostedDateStalenessSpec(unittest.TestCase):
|
||||
"""Step 3 must consume the posted_date #391 persists.
|
||||
|
||||
The field exists because a 27-month-old posting ranked Strong Fit at
|
||||
position 1 of 133 (#390): the scoring agent noticed the age and wrote it
|
||||
into prose nothing reads. Persistence alone changes nothing - these pin
|
||||
that /rank actually derives a signal from the stored date, and that the
|
||||
signal keeps the schema's own boundary rules (flag never veto, no
|
||||
inference for absent values, rule 6's defensive parse).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.step3 = _sections(COMMAND.read_text(encoding="utf-8")).get(
|
||||
"Step 3: Aggregate and Rank", ""
|
||||
)
|
||||
self.assertTrue(self.step3, "Step 3 section missing from rank.md")
|
||||
# The spec hard-wraps its prose; assertions match against collapsed
|
||||
# whitespace so a rewrap never fails a pin the text still honors.
|
||||
self.flat = " ".join(self.step3.split())
|
||||
|
||||
def test_step3_consumes_posted_date(self):
|
||||
self.assertIn(
|
||||
"`posted_date`",
|
||||
self.step3,
|
||||
"Step 3 never reads the posted_date /scrape persists, so a posting's "
|
||||
"age is stored but still invisible at rank time - the exact #390 gap",
|
||||
)
|
||||
self.assertIn(
|
||||
"⚠",
|
||||
self.step3.split("`posted_date`", 1)[1][:600],
|
||||
"the staleness rule must surface age as a visible ⚠ marker, like the "
|
||||
"location and language FLAG treatments",
|
||||
)
|
||||
|
||||
def test_staleness_is_a_flag_never_a_veto(self):
|
||||
self.assertRegex(
|
||||
self.flat,
|
||||
r"[Aa]ge is a signal, never a veto",
|
||||
"staleness must keep FLAG semantics - the #390 posting was 27 months "
|
||||
"old AND still live, so excluding on age would bury real openings",
|
||||
)
|
||||
|
||||
def test_staleness_never_inferred_for_absent_values(self):
|
||||
self.assertRegex(
|
||||
self.flat,
|
||||
r"no `posted_date`.*no flag and no guess",
|
||||
"entries predating the field must get no staleness signal - inferring "
|
||||
"age from first_seen would flag jobs on a date nobody posted",
|
||||
)
|
||||
self.assertIn(
|
||||
"`first_seen`",
|
||||
self.step3,
|
||||
"the rule must name first_seen as the forbidden inference source",
|
||||
)
|
||||
|
||||
def test_staleness_parses_posted_date_defensively(self):
|
||||
self.assertRegex(
|
||||
self.flat,
|
||||
r"defensive-parse rule applies wherever a stored `posted_date` is compared",
|
||||
"posted_date comparisons must carry rule 6's defensive-parse rule - the "
|
||||
"contract test pins the field's presence, not its format, and portals "
|
||||
"have shipped free-text shapes into stored date fields before",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user