mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
The deadline is written at every moment it is provably in hand and survives every write that follows: seen_jobs.json base field, /rank stored-value urgency + expiry sweep with Step 4 persistence, tracker 14th column with header-line-only migration for existing files, /scrape-path extraction (assistant SKILL.md 1.3.2 -> 1.3.3), preserve-unparsed-fields in /outcome and /gmail-sync, notion-sync deadline precedence. Design, scope analysis, and the folded refinements by jakob1379 (#319, #328). Co-authored-by: Jakob Stender Guldberg <17257805+jakob1379@users.noreply.github.com>
This commit is contained in:
co-authored by
Jakob Stender Guldberg
parent
5c6ffe8aa7
commit
c855e11d22
@@ -35,7 +35,7 @@ SCRAPER = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
|
||||
|
||||
TRACKER_HEADER = (
|
||||
"date,company,sector,role,role_type,channel,status,contact_person,"
|
||||
"fit_rating,notes,cv_file,cover_letter_file,source"
|
||||
"fit_rating,notes,cv_file,cover_letter_file,source,deadline"
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +67,17 @@ class ApplyRecordsApplication(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_tracker_header_matches_outcome(self):
|
||||
"""Byte-identical, or the two commands create incompatible CSVs."""
|
||||
"""Byte-identical, or the two commands create incompatible CSVs.
|
||||
|
||||
The exact-equality loop below is load-bearing, not decoration. `assertIn`
|
||||
on its own cannot see an *additive* drift: a 13-column header is a
|
||||
substring of a 14-column one, so appending a column to `/apply` and
|
||||
forgetting `/outcome` passed this test cleanly until the loop was added.
|
||||
|
||||
It is also what makes the constant-only assertions in this class mean
|
||||
anything: they reason about TRACKER_HEADER, and this is the test that
|
||||
anchors TRACKER_HEADER to what both spec files actually say.
|
||||
"""
|
||||
self.assertIn(TRACKER_HEADER, OUTCOME.read_text(encoding="utf-8"))
|
||||
self.assertIn(
|
||||
TRACKER_HEADER,
|
||||
@@ -75,6 +85,29 @@ class ApplyRecordsApplication(unittest.TestCase):
|
||||
"Step 6b's header drifted from outcome.md's - whichever command ran "
|
||||
"first would decide the schema",
|
||||
)
|
||||
for name, text in (("outcome.md", OUTCOME.read_text(encoding="utf-8")),
|
||||
("apply.md Step 6b", self.step_6b)):
|
||||
header = next(
|
||||
(ln.strip() for ln in text.splitlines() if ln.strip().startswith("date,company,")),
|
||||
None,
|
||||
)
|
||||
self.assertEqual(
|
||||
header,
|
||||
TRACKER_HEADER,
|
||||
f"{name}'s header line is not exactly the canonical header - a column "
|
||||
"appended to one file and not the other leaves both containing the "
|
||||
"shorter header as a substring, which assertIn alone cannot catch",
|
||||
)
|
||||
|
||||
def test_tracker_header_ends_with_deadline(self):
|
||||
"""/apply appends rows with one field per header column, so inserting
|
||||
`deadline` anywhere but the end shifts every value in every existing
|
||||
row by one position."""
|
||||
self.assertTrue(
|
||||
TRACKER_HEADER.endswith(",deadline"),
|
||||
"deadline must be the last column - a mid-header insert shifts every "
|
||||
"existing row's values by one position",
|
||||
)
|
||||
|
||||
def test_step_runs_before_the_optional_offer_that_ends_the_turn(self):
|
||||
"""The optional application-form offer asks the user a question.
|
||||
@@ -250,5 +283,55 @@ class ApplyArchivesThePosting(unittest.TestCase):
|
||||
self.assertIn(needle, section(path, heading), why)
|
||||
|
||||
|
||||
class DeadlineSurvivesEveryWrite(unittest.TestCase):
|
||||
"""#319: the deadline is carried through the whole pipeline and never dropped.
|
||||
|
||||
The header migration must be header-line-only (inserting it mid-column
|
||||
shifts every value of every existing row), and every path that rewrites
|
||||
a tracker row (/outcome Step 4, /gmail-sync Step 7a) must preserve
|
||||
fields it does not parse - the deadline is the first such field.
|
||||
"""
|
||||
|
||||
CASES = [
|
||||
(APPLY, "### Step 6b: Record the Application", "append `,deadline` to the header line only",
|
||||
"a mid-header insert shifts every existing row's values by one position"),
|
||||
(OUTCOME, "## Step 1: Load State and Identify the Application",
|
||||
"append `,deadline` to the header line only",
|
||||
"the two commands must migrate identically, or whichever runs first sets the schema"),
|
||||
(APPLY, "## Step 0: Parse Input", "application deadline",
|
||||
"Step 6b's value is supposed to come from Step 0's extraction, so the extraction "
|
||||
"must be stated where the posting text is still held in full"),
|
||||
(APPLY, "### Step 6b: Record the Application", "Never guess one",
|
||||
"the deadline must stay empty when the posting states none - a guessed date is "
|
||||
"the urgency clock firing on a date nobody set"),
|
||||
(APPLY, "### Step 6b: Record the Application", "leave an existing deadline alone",
|
||||
"absence is not a correction: a run that extracted no deadline must not blank "
|
||||
"the one /apply already wrote"),
|
||||
(OUTCOME, "## Step 1: Load State and Identify the Application", "Deadline urgency",
|
||||
"a drafted row has nothing applied so the quiet clock must not run on it - the "
|
||||
"deadline is the only clock that applies, and it must not be omitted"),
|
||||
(OUTCOME, "## Step 1: Load State and Identify the Application", "never chased",
|
||||
"surfacing the deadline must not drag drafted rows into the follow-up offer"),
|
||||
(OUTCOME, "## Step 4: Update the Tracker", "preserve every other field of the row",
|
||||
"a status update that rewrites the row would blank the deadline column"),
|
||||
(GMAIL_SYNC, "### Step 7a: Write Approved Updates", "preserve every other field",
|
||||
"the sync path rewrites the row too - it must carry the same preservation rule"),
|
||||
(NOTION_SYNC, None, "**Deadline precedence: the tracker wins too**",
|
||||
"the tracker's deadline (written from the posting the application was actually "
|
||||
"built on) must override the scraper's stored value"),
|
||||
(NOTION_SYNC, None, "tracker `deadline` column",
|
||||
"the Deadine property must name the tracker column as its source"),
|
||||
(SKILL, "### Step 3b: Record the Application", "`deadline` is the application deadline",
|
||||
"the /scrape path reaches Step 3b without running /apply Step 0, so it must "
|
||||
"still be told what the field is and where it comes from"),
|
||||
]
|
||||
|
||||
def test_deadline_survives_every_write(self):
|
||||
for path, heading, needle, why in self.CASES:
|
||||
with self.subTest(file=path.name, rule=needle):
|
||||
haystack = section(path, heading) if heading else path.read_text(encoding="utf-8")
|
||||
self.assertIn(needle, haystack, why)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -78,6 +78,30 @@ class RankCommandSpec(unittest.TestCase):
|
||||
"schema note must say old entries lacking strengths/gaps are tolerated, never backfilled",
|
||||
)
|
||||
|
||||
def test_job_scraper_schema_carries_deadline(self):
|
||||
"""Pins the base field in the seen_jobs.json structure block and the
|
||||
never-infer note. Step 2's detail fetch already extracts the deadline, so
|
||||
/scrape writes it at first sight instead of leaving it to /rank (#319).
|
||||
"""
|
||||
text = SCRAPER_SKILL.read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
'"deadline": "YYYY-MM-DD" | null',
|
||||
text,
|
||||
"the seen_jobs.json structure block must carry the deadline field, "
|
||||
"or every later run has no stored value to re-derive urgency from",
|
||||
)
|
||||
self.assertIn(
|
||||
"never infer a deadline",
|
||||
text,
|
||||
"the schema note must forbid guessing a deadline from null or from a missing key",
|
||||
)
|
||||
self.assertIn(
|
||||
"base field rather than a `/rank` extension",
|
||||
text,
|
||||
"the note must say the deadline is written when the job is first seen, "
|
||||
"not only when /rank re-scores it",
|
||||
)
|
||||
|
||||
def test_step2_schema_includes_language_gate_fields(self):
|
||||
sections = _sections(COMMAND.read_text(encoding="utf-8"))
|
||||
step2 = sections.get("Step 2: Batch-Fetch and Score", "")
|
||||
@@ -127,6 +151,84 @@ class RankCommandSpec(unittest.TestCase):
|
||||
"Step 4 must call out that the veto fields (location/language_gate/language_note) are not optional extras",
|
||||
)
|
||||
|
||||
def test_step4_persists_deadline(self):
|
||||
"""Sibling of test_step4_persists_language_gate_and_language_note: the deadline was
|
||||
computed in Step 2 and acted on in Step 3, but never written to seen_jobs.json, so
|
||||
the urgency marker fired exactly once and a later run had to re-fetch the posting to
|
||||
recover the date (#319). Pins the persistence in the Step 4 field list.
|
||||
"""
|
||||
sections = _sections(COMMAND.read_text(encoding="utf-8"))
|
||||
step4 = sections.get("Step 4: Update State", "")
|
||||
self.assertIn('"deadline"', step4, "Step 4 must persist the deadline into seen_jobs.json")
|
||||
self.assertIn(
|
||||
"from the same Step 2 JSON",
|
||||
step4,
|
||||
"Step 4 must source the persisted deadline from the scoring agent's JSON, not from a guess",
|
||||
)
|
||||
self.assertIn(
|
||||
"absence is not a correction",
|
||||
step4,
|
||||
"Step 4 must keep an existing stored deadline when the agent returned null, "
|
||||
"so a fresh run never blanks a date the scraper already recorded",
|
||||
)
|
||||
|
||||
def test_step3_reads_stored_deadline_without_fetch(self):
|
||||
"""Persisting alone does not re-fire the marker: Step 3 must read the stored
|
||||
deadline back so urgency is re-derived on every run without re-reading the
|
||||
posting (which is the dead-URL source the field exists to replace).
|
||||
"""
|
||||
sections = _sections(COMMAND.read_text(encoding="utf-8"))
|
||||
step3 = sections.get("Step 3: Aggregate and Rank", "")
|
||||
self.assertIn(
|
||||
"stored `deadline`",
|
||||
step3,
|
||||
"Step 3 must take the deadline from seen_jobs.json for a job that already carries one",
|
||||
)
|
||||
self.assertIn(
|
||||
"costs no fetch",
|
||||
step3,
|
||||
"Step 3 must state that the stored value costs no fetch - that is the entire point of persisting it",
|
||||
)
|
||||
|
||||
def test_step3_documents_expiry_sweep_over_ranked_entries(self):
|
||||
"""Rule 6: entries this run did not re-score still get their stored deadline checked,
|
||||
enforcing the only-open-positions rule beyond the moment of fetching.
|
||||
"""
|
||||
sections = _sections(COMMAND.read_text(encoding="utf-8"))
|
||||
step3 = sections.get("Step 3: Aggregate and Rank", "")
|
||||
self.assertIn(
|
||||
"Expiry sweep",
|
||||
step3,
|
||||
"Step 3 must document a sweep over already-ranked entries this run did not re-score",
|
||||
)
|
||||
self.assertIn(
|
||||
"date comparison against values already on disk",
|
||||
step3,
|
||||
"the sweep must be a pure on-disk comparison - no fetch, no agent",
|
||||
)
|
||||
step5 = sections.get("Job Ranking - YYYY-MM-DD", "")
|
||||
self.assertIn(
|
||||
"Closing soon",
|
||||
step5,
|
||||
"Step 5's template must name the Closing soon heading rule 6 lists under",
|
||||
)
|
||||
|
||||
def test_step4_persists_the_sweeps_expiry(self):
|
||||
"""The sweep must write its result, or it reproduces the very bug it fixes.
|
||||
|
||||
Step 4's expiry line is scoped to what the Step 2 agents returned. The sweep
|
||||
runs over entries this run did not re-score, so without its own persistence
|
||||
line the transition happens in reasoning only and disk never changes.
|
||||
"""
|
||||
sections = _sections(COMMAND.read_text(encoding="utf-8"))
|
||||
step4 = sections.get("Step 4: Update State", "")
|
||||
self.assertIn(
|
||||
"retired by Step 3's rule 6 sweep",
|
||||
step4,
|
||||
"Step 4 must persist the Step 3 rule 6 sweep's expiries, not just the ones "
|
||||
"the scoring agents reported",
|
||||
)
|
||||
|
||||
def test_step5_documents_language_flag_marker(self):
|
||||
# Note: _sections() splits on every "\n## " line, including the "## Job
|
||||
# Ranking - YYYY-MM-DD" line inside Step 5's own fenced example template -
|
||||
|
||||
@@ -53,6 +53,18 @@ class UpskillSkillSpec(unittest.TestCase):
|
||||
"Step 2 must document the graceful-degradation clause for entries scored before gaps existed",
|
||||
)
|
||||
|
||||
def test_step2_column_list_keeps_in_phase_with_tracker_header(self):
|
||||
"""/upskill reads the tracker, so its enumeration of the columns must
|
||||
match the header /apply writes - the deadline column (#319) is the
|
||||
first column to be added since the list was written."""
|
||||
sections = _sections(SKILL.read_text(encoding="utf-8"))
|
||||
step2 = sections.get("Step 2: Load Data", "")
|
||||
self.assertIn(
|
||||
"source, deadline",
|
||||
step2,
|
||||
"Step 2's column list lost the deadline column the tracker header now ends with",
|
||||
)
|
||||
|
||||
def test_step3_documents_dedupe_and_gap_precedence(self):
|
||||
sections = _sections(SKILL.read_text(encoding="utf-8"))
|
||||
step3 = sections.get("Step 3: Pass 1 — Hard Skill Diff", "")
|
||||
|
||||
Reference in New Issue
Block a user