mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
fix(rank): rename the location verdict field to location_verdict
"location" meant a place in scraper output and a PASS/FAIL/FLAG verdict in /rank's persistence - one key, two meanings, in the same store, with ranking able to overwrite the commute-filter place with "PASS". The verdict now lives in location_verdict; legacy entries are read compatibly and migrated on re-write. Also completes the seen_jobs schema enumeration (F27 Part A): the do-not-drop instruction now names location_verdict/language_gate/language_note. Review finding F27 (2026-08-19), decision approved by Mads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a306912133
commit
d4e0c64c3c
@@ -49,7 +49,7 @@ Each agent returns a JSON array, one object per job:
|
||||
"key": "<the job's key in seen_jobs.json>",
|
||||
"status": "scored" | "expired",
|
||||
"scores": { "technical": 0-100, "experience": 0-100, "behavioral": 0-100, "career": 0-100 },
|
||||
"location": "PASS" | "FAIL" | "FLAG",
|
||||
"location_verdict": "PASS" | "FAIL" | "FLAG",
|
||||
"language_gate": "PASS" | "FAIL" | "FLAG",
|
||||
"language_note": "<posting requirement + declared level, only when FLAG or FAIL>",
|
||||
"deadline": "YYYY-MM-DD" | null,
|
||||
@@ -84,7 +84,7 @@ Sort by overall score (descending), urgency as tiebreaker.
|
||||
|
||||
Update `job_scraper/seen_jobs.json` in place - these fields are additive to the scraper's schema:
|
||||
|
||||
- Ranked jobs: set `"status": "ranked"` and add `"rank_score": <overall>`, `"rank_verdict": "<band>"`, `"rank_date": "YYYY-MM-DD"`, `"location": "PASS"/"FAIL"/"FLAG"`, `"language_gate": "PASS"/"FAIL"/"FLAG"`, `"language_note"` (omit or `null` when `language_gate` is `PASS`), `"deadline": "YYYY-MM-DD" | null` from the same Step 2 JSON (replace the stored value when the agent returned a different one - a fresh fetch is the freshest source; leave it alone when the agent returned `null`, absence is not a correction - a fetch that degraded to a listing page returns no deadline, and taking that as "the posting dropped its deadline" would erase a real date and, because rule 6 leaves an entry with no stored `deadline` alone, quietly make that job immortal to the sweep), plus `"strengths": [...]` and `"gaps": [...]` copied from the scoring agent's Step 2 JSON for that job. These veto fields are as important to persist as the score itself - without them, nothing later (a re-read of `seen_jobs.json`, a debugging session, the user asking "why was this excluded") can recover why a job did or didn't make the shortlist.
|
||||
- Ranked jobs: set `"status": "ranked"` and add `"rank_score": <overall>`, `"rank_verdict": "<band>"`, `"rank_date": "YYYY-MM-DD"`, `"location_verdict": "PASS"/"FAIL"/"FLAG"` (never the bare `location` key - that is the scraper's place field, e.g. "Aarhus, Denmark", and overwriting it with a verdict destroys the commute-filter data; an entry ranked before this rename may carry a legacy PASS/FAIL/FLAG string in `location` - read that as the verdict when `location_verdict` is absent, and move it to `location_verdict` when re-writing the entry), `"language_gate": "PASS"/"FAIL"/"FLAG"`, `"language_note"` (omit or `null` when `language_gate` is `PASS`), `"deadline": "YYYY-MM-DD" | null` from the same Step 2 JSON (replace the stored value when the agent returned a different one - a fresh fetch is the freshest source; leave it alone when the agent returned `null`, absence is not a correction - a fetch that degraded to a listing page returns no deadline, and taking that as "the posting dropped its deadline" would erase a real date and, because rule 6 leaves an entry with no stored `deadline` alone, quietly make that job immortal to the sweep), plus `"strengths": [...]` and `"gaps": [...]` copied from the scoring agent's Step 2 JSON for that job. These veto fields are as important to persist as the score itself - without them, nothing later (a re-read of `seen_jobs.json`, a debugging session, the user asking "why was this excluded") can recover why a job did or didn't make the shortlist.
|
||||
- Dead or past-deadline jobs: set `"status": "expired"`
|
||||
- Entries retired by Step 3's rule 6 sweep: set `"status": "expired"` for those too, and leave every other field on them untouched. The sweep reasons over entries this run never scored, so without this line its conclusion would live only in the report and the same expiry would be re-derived from the same stored date on every future run.
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ The `portal` field records which CLI skill produced the job (results are already
|
||||
|
||||
The `source` field records which mechanism produced the entry: `cli` for Step 1b portal-CLI output, `websearch` for the Step 1c fallback. This is what keeps a ghost-job report diagnosable after the run's summary is gone: a stored entry whose URL later resolves to nothing (or to a different job) reads very differently depending on whether it came from live CLI output or from a search index that can be weeks stale - and a presented job with no entry here at all points at fabrication, which Rule 1 forbids. Entries written before this field existed lack it; never backfill it - the mechanism was not recorded.
|
||||
|
||||
`/rank` extends this schema additively: ranked entries also carry `rank_score` (0–100 overall score), `rank_verdict` (fit band, e.g. "strong fit"), `rank_date` (ISO date of ranking), and `strengths`/`gaps` (1-3 verbatim bullets each, copied from the scoring agent's findings). The `status` field is set to `"ranked"`. Do not drop any of these fields when re-writing entries. Entries ranked before `strengths`/`gaps` existed simply lack them; readers tolerate their absence and never backfill by guessing.
|
||||
`/rank` extends this schema additively: ranked entries also carry `rank_score` (0–100 overall score), `rank_verdict` (fit band, e.g. "strong fit"), `rank_date` (ISO date of ranking), the veto fields `location_verdict` and `language_gate` (both PASS/FAIL/FLAG) with `language_note` (the quoted requirement explaining a non-PASS), and `strengths`/`gaps` (1-3 verbatim bullets each, copied from the scoring agent's findings). The `status` field is set to `"ranked"`. Do not drop any of these fields when re-writing entries. Entries ranked before `strengths`/`gaps` existed simply lack them; readers tolerate their absence and never backfill by guessing. Entries ranked before the verdict rename may carry a legacy PASS/FAIL/FLAG string in `location` - read that as the verdict when `location_verdict` is absent; in fresh entries `location` is always a place, never a verdict.
|
||||
|
||||
`deadline` is a base field rather than a `/rank` extension: Step 2's detail fetch already extracts the application deadline, so it is written when the job is first seen and refreshed by `/rank` Step 4 when a scoring agent returns a different value. `null` means the posting states no deadline; a missing key means the entry predates this field - **never infer a deadline** from either, and never backfill by guessing.
|
||||
|
||||
|
||||
@@ -65,6 +65,16 @@ per-file diff commands.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`/rank` persists its location verdict as `location_verdict`** - the bare `location`
|
||||
key meant two incompatible things in `seen_jobs.json`: a place (scraper search output,
|
||||
driving the commute filter) and a PASS/FAIL/FLAG verdict (`/rank` Step 4), so ranking
|
||||
could overwrite "Aarhus, Denmark" with "PASS" and no reader could tell which meaning a
|
||||
stored value carried. Legacy entries are read compatibly (a PASS/FAIL/FLAG string in
|
||||
`location` counts as the verdict when `location_verdict` is absent) and migrated on
|
||||
re-write. The `seen_jobs` schema note in `job-scraper/SKILL.md` now also enumerates
|
||||
`location_verdict`/`language_gate`/`language_note`, so its "do not drop any of these
|
||||
fields" instruction finally covers the fields `/rank` calls as important as the score.
|
||||
Pinned by two new tests in `tests/test_rank_command.py`.
|
||||
- **`linkedin-search detail` drops the `applyUrl` field** - the extraction regex
|
||||
assumed `class=` before `href=` and never matched LinkedIn's real markup (`null` on
|
||||
every live posting since the markup ordering differs), and fixing the regex would only
|
||||
|
||||
@@ -105,6 +105,46 @@ class RankCommandSpec(unittest.TestCase):
|
||||
"not only when /rank re-scores it",
|
||||
)
|
||||
|
||||
def test_verdict_is_written_to_location_verdict_not_bare_location(self):
|
||||
"""`location` meant two incompatible things in seen_jobs.json: a place
|
||||
(scraper search output, driving the commute filter) and a PASS/FAIL/FLAG
|
||||
verdict (/rank Step 4), so a ranked entry could overwrite "Aarhus,
|
||||
Denmark" with "PASS" and no reader could tell which meaning a stored
|
||||
value carried (review finding F27B, 2026-08-19)."""
|
||||
text = COMMAND.read_text(encoding="utf-8")
|
||||
self.assertIn('"location_verdict"', text, "Step 2's agent JSON must use location_verdict")
|
||||
self.assertIn(
|
||||
'"location_verdict": "PASS"/"FAIL"/"FLAG"',
|
||||
text,
|
||||
"Step 4 must persist the verdict under location_verdict",
|
||||
)
|
||||
self.assertNotIn(
|
||||
'"location":',
|
||||
text,
|
||||
"the PASS/FAIL/FLAG verdict must never be written to the bare "
|
||||
"location key, which the scraper uses for a place",
|
||||
)
|
||||
self.assertIn(
|
||||
"legacy",
|
||||
text,
|
||||
"Step 4 must carry a migration rule for entries that stored the "
|
||||
"verdict under the old location key",
|
||||
)
|
||||
|
||||
def test_job_scraper_schema_note_enumerates_the_veto_fields(self):
|
||||
"""SKILL.md's "do not drop any of these fields" instruction cannot
|
||||
protect fields it does not name - and it omitted exactly the three
|
||||
rank.md calls as important to persist as the score itself (review
|
||||
finding F27 Part A, 2026-08-19)."""
|
||||
text = SCRAPER_SKILL.read_text(encoding="utf-8")
|
||||
for field in ("location_verdict", "language_gate", "language_note"):
|
||||
self.assertIn(
|
||||
field,
|
||||
text,
|
||||
f"the seen_jobs schema note must enumerate {field} so the "
|
||||
"do-not-drop instruction covers it",
|
||||
)
|
||||
|
||||
def test_evaluation_framework_acknowledges_language_gate_tracking(self):
|
||||
"""04-job-evaluation.md is the authoritative file /rank tells its agents
|
||||
to read. Its Language Gate preamble once said the gate result "is not a
|
||||
|
||||
Reference in New Issue
Block a user