mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-11 17:37:59 +00:00
feat(scrape,rank): record and surface a posting's employment type (#619)
* feat(scrape,rank): record and surface a posting's employment type Nullable employment_type (full_time|part_time|contract|freelance) in the seen_jobs.json entry, read from posting text at Step 3; /rank shows it on the shortlist line and flags a mismatch with a stated profile preference (never a veto). No pay normalisation, no new profile field. * fix(scrape,rank): address review on employment type - scope the never-a-veto test to the Employment type paragraph instead of matching the phrase anywhere in rank.md - count portal `detail` output as a source next to the posting text - bracket the optional ` · <employment type>` so a null type leaves no dangling separator - drop the CHANGELOG.md hunk: the file is frozen since #800 and release notes come from the PR title Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mv8AZjCnuGt98qsM9zuCFZ --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
71c49f3d83
commit
5fe97b6ab7
3 files changed
+76
-1
No files matched your search
@@ -166,9 +166,11 @@ Swept <S> previously ranked entries (<E> newly expired, <C> closing soon).
|
||||
| 1 | 78 | Strong Fit | ... | ... | ... | ... | 🔥 | [Link](...) |
|
||||
|
||||
### Why these ranked highest
|
||||
**1. <Title> at <Company> (78)** - [2-3 strength bullets and the honest gap, from the agent's findings]
|
||||
**1. <Title> at <Company> (78)**[ · <employment type>] - [2-3 strength bullets and the honest gap, from the agent's findings]
|
||||
[repeat for each shortlisted job]
|
||||
|
||||
**Employment type:** show a shortlisted entry's stored `employment_type` (`full_time`, `part_time`, `contract`, `freelance`) after the score in its "Why these ranked highest" line, as above. The ` · <employment type>` part is bracketed because it is optional: leave out the whole bracket, separator included, when the value is `null` or the key is missing, so no dangling `·` is left behind. If the profile or `CLAUDE.md` states what kind of engagement the user wants (for example "contract only" under career goals) and the stored type differs, add a visible ⚠ marker naming both (e.g. "⚠ full_time, profile prefers contract"). Same treatment as a location or language FLAG: a signal for the user to judge, never a veto, and no score change. A `null` or absent type gets no marker and no guess; this costs no fetch because `/scrape` already stored the value.
|
||||
|
||||
### Closing soon
|
||||
| Deadline | Title | Company | URL |
|
||||
|----------|-------|---------|-----|
|
||||
|
||||
@@ -147,6 +147,8 @@ For each new job, do a rapid fit check (NOT the full evaluation from `04-job-eva
|
||||
- **Medium match**: Role is adjacent to your experience
|
||||
- **Low match**: Role requires significant skills you lack
|
||||
|
||||
**Employment type:** while reading the posting for this check, note how it says the engagement is structured and record it as `employment_type` in Step 4 - one of `full_time`, `part_time`, `contract`, `freelance`, or `null`. Take it only from what the posting itself states, in its text or in the portal's `detail` output from Step 2 ("permanent", "6-month contract", "freelance", "part-time"); a posting that says nothing, or says something ambiguous, is `null`. Never infer it from the title, the company or the portal.
|
||||
|
||||
**Language override:** before assigning a match level, check the posting against `04-job-evaluation.md`'s Language Gate (a required language you haven't declared at all in your CLAUDE.md Languages table). A required language that's entirely undeclared overrides skill fit: mark it **Low** regardless of how well the skills align, and name it in the highlight bullets so it isn't buried under an otherwise-good-looking match. A **declared** language at a requirement that reads higher than your declared level is *not* an override — score fit normally, but add a red-flag bullet under that job's highlights (Step 5) quoting the posting's requirement next to your declared level, so the gap is visible without being auto-downgraded.
|
||||
|
||||
### Step 4: Deduplicate & Store
|
||||
@@ -170,6 +172,7 @@ It prints one line: the canonical key for that posting. The key must be a pure f
|
||||
"first_seen": "YYYY-MM-DD",
|
||||
"posted_date": "YYYY-MM-DD" | null,
|
||||
"deadline": "YYYY-MM-DD" | null,
|
||||
"employment_type": "full_time" | "part_time" | "contract" | "freelance" | null,
|
||||
"fit": "high/medium/low",
|
||||
"status": "new/skipped/ranked/expired/unreachable",
|
||||
"portal": "<source portal skill, e.g. jobindex-search>",
|
||||
@@ -197,6 +200,8 @@ The `source` field records which mechanism produced the entry: `cli` for Step 1b
|
||||
|
||||
`posted_date` is the posting's own publication date, taken from the `date` field Step 2's contract already guarantees on every portal CLI's search output. Step 1b uses that date to scope the run to the last 14 days and then drops it, so nothing downstream can distinguish a posting published yesterday from one published two years ago - `first_seen` is when this scraper first saw the entry, not when the employer posted it. Persisting it makes Step 1b's window auditable after the run and gives `/rank` a freshness signal to weigh, instead of rediscovering the date and recording it in prose that nothing reads. That gap landed for real: a freehire-search posting dated 2024-05-13 was scraped and ranked Strong Fit at position 1 of 133, its own scoring note observing the listing "may be long stale" with nothing able to act on it. `null` means the portal returned no date for that result (the CLIs emit `date: null` when a listing omits it); a missing key means the entry predates this field - **never infer a posting date** from either, and never backfill by guessing.
|
||||
|
||||
`employment_type` is what the posting itself says about how the work is engaged, read at Step 3's fit check from the posting text and `detail` output already fetched, so it costs nothing extra. It is descriptive only: it never changes `fit` and nothing in `/scrape` filters on it. `null` means the posting did not say; a missing key means the entry predates this field - **never infer an employment type** from either, and never backfill by guessing. Pay is deliberately not normalised: rates, currencies and salary-versus-day-rate are posting-specific and stay in the posting.
|
||||
|
||||
3. Only present jobs NOT already in the seen list (matched by URL or
|
||||
company+title) or tracker.
|
||||
|
||||
|
||||
@@ -136,6 +136,74 @@ class SeenJobsPostingDateTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
RANK_COMMAND = REPO_ROOT / ".claude" / "commands" / "rank.md"
|
||||
EMPLOYMENT_TYPES = ("full_time", "part_time", "contract", "freelance")
|
||||
|
||||
|
||||
class SeenJobsEmploymentTypeTests(unittest.TestCase):
|
||||
"""The engagement type a posting states must be stored and surfaced, never guessed.
|
||||
|
||||
Part-time, contract and freelance postings reach the shortlist
|
||||
indistinguishable from permanent roles: Step 3 reads the posting but its
|
||||
only outputs are a fit band and highlights, so a "6-month contract" is only
|
||||
visible if the reader happens to see the title. Persisting it as a nullable
|
||||
field lets /rank show it and flag a mismatch with the user's stated
|
||||
preference, without any new profile field.
|
||||
"""
|
||||
|
||||
def test_step4_schema_persists_employment_type(self):
|
||||
self.assertIn(
|
||||
"employment_type",
|
||||
derive_stored_fields(),
|
||||
"Step 4's seen_jobs.json schema stores no employment type",
|
||||
)
|
||||
|
||||
def test_schema_lists_every_allowed_value_and_null(self):
|
||||
text = SCRAPER_SKILL.read_text(encoding="utf-8")
|
||||
line = next(l for l in text.splitlines() if l.strip().startswith('"employment_type"'))
|
||||
for value in EMPLOYMENT_TYPES:
|
||||
self.assertIn(f'"{value}"', line)
|
||||
self.assertIn("null", line)
|
||||
|
||||
def test_employment_type_semantics_are_documented(self):
|
||||
text = SCRAPER_SKILL.read_text(encoding="utf-8")
|
||||
self.assertRegex(
|
||||
text,
|
||||
r"never infer an employment type",
|
||||
"employment_type must carry the same never-backfill rule as `posted_date`",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _rank_employment_paragraph() -> str:
|
||||
text = RANK_COMMAND.read_text(encoding="utf-8")
|
||||
start = text.find("**Employment type:**")
|
||||
if start == -1:
|
||||
return ""
|
||||
end = text.find("\n\n", start)
|
||||
return text[start:] if end == -1 else text[start:end]
|
||||
|
||||
def test_rank_shows_it_as_a_flag_never_a_veto(self):
|
||||
# Scoped to the paragraph: "never a veto" is said about age and the
|
||||
# posting guard elsewhere in the file, so a file-wide search would
|
||||
# keep passing if this paragraph lost the promise.
|
||||
paragraph = self._rank_employment_paragraph()
|
||||
self.assertIn("employment_type", paragraph, "/rank never reads the stored employment type")
|
||||
self.assertRegex(paragraph, r"never a veto", "the mismatch flag must not veto")
|
||||
|
||||
def test_rank_marks_the_type_and_its_separator_as_optional(self):
|
||||
# A null type must not leave a dangling "·" after the score.
|
||||
text = RANK_COMMAND.read_text(encoding="utf-8")
|
||||
self.assertIn("(78)**[ \u00b7 <employment type>]", text)
|
||||
self.assertRegex(self._rank_employment_paragraph(), r"separator included")
|
||||
|
||||
def test_portal_detail_output_counts_as_a_source(self):
|
||||
# Step 3 reads the posting text, but a portal's `detail` output is what
|
||||
# Step 2 fetched; the rule must not restrict the source to one of them.
|
||||
text = SCRAPER_SKILL.read_text(encoding="utf-8")
|
||||
start = text.index("**Employment type:**")
|
||||
self.assertIn("`detail` output", text[start:text.index("\n\n", start)])
|
||||
|
||||
|
||||
class SeenJobsDedupContinuityTests(unittest.TestCase):
|
||||
"""The new key rule must not replay jobs stored under legacy keys."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user