fix(upskill,notion-sync): join tracker rows to ranked entries on title, the field entries carry (#614)

Both specs said to match "on company + role" with "casefold + strip on
both fields", but a seen_jobs.json entry has title and no role key (the
job-scraper schema), so read literally the lookup never matched: /upskill
then weighted the same job twice, once as a tracker row and once as a
ranked entry - the double-count its dedupe step exists to prevent - and
/notion-sync split one job into two rows with different Keys.
tools/rank_state.py already does the cross-name join (tracker role
against entry title); both specs now say so.

Two derived tests read the entry schema and require the join wording to
name title for the entry and role for the tracker; they fail on master.
This commit is contained in:
Ayobami Adegoke authored and GitHub committed 2026-10-17 20:01:34 +02:00
1 parent 709339758a
commit 2697fa46fb
5 files changed
+46 -3

No files matched your search

+12
View File
@@ -43,6 +43,18 @@ class NotionSyncCommandSpec(unittest.TestCase):
"notion_sync.json is personal state and must never be committable",
)
def test_step2_join_names_the_field_ranked_entries_actually_carry(self):
# seen_jobs.json entries carry `title` (job-scraper SKILL.md schema), the
# tracker carries `role`. "matched ... on company + role" read as a
# lookup of `role` on the entry, which never matches, so one job became
# two Notion rows with different Keys. The join is role-vs-title.
text = COMMAND.read_text(encoding="utf-8")
line = next(l for l in text.splitlines() if "matched to `seen_jobs.json` entries" in l)
self.assertIn("company + title", line)
self.assertIn("`title`", line)
self.assertIn("`role`", line)
self.assertNotIn("company + role", line)
def test_privacy_rule_documents_never_sync(self):
text = COMMAND.read_text(encoding="utf-8")
self.assertIn(
+20 -1
View File
@@ -19,6 +19,7 @@ except ImportError:
REPO = Path(__file__).resolve().parent.parent
SKILL = REPO / ".claude" / "skills" / "upskill" / "SKILL.md"
SCRAPER = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
def _sections(text: str) -> dict[str, str]:
@@ -68,7 +69,7 @@ class UpskillSkillSpec(unittest.TestCase):
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", "")
self.assertIn("case-insensitive company + role", step3, "Step 3 must specify the dedupe key")
self.assertIn("case-insensitive company + title", step3, "Step 3 must specify the dedupe key")
self.assertIn(
"/notion-sync",
step3,
@@ -82,6 +83,24 @@ class UpskillSkillSpec(unittest.TestCase):
self.assertIn("(100 - fit_rating) / 100", step3)
self.assertIn("(100 - rank_score) / 100", step3)
def test_step3_dedupe_names_the_field_ranked_entries_actually_carry(self):
# The tracker has a `role` column; a seen_jobs.json entry has `title`
# (job-scraper SKILL.md schema) and no `role` key at all. Step 3 told
# the reader to match on "company + role" with "casefold + strip on
# both fields", which reads as a literal lookup of `role` on the entry:
# that never matches, so the dedupe silently failed and Step 3.2's
# "one weight per job" counted the same job twice. The join is
# role-vs-title, as tools/rank_state.py implements it.
schema = SCRAPER.read_text(encoding="utf-8")
schema_block = schema[schema.index('"<key from tools/job_key.py>"'): schema.index("```", schema.index('"<key from tools/job_key.py>"'))]
self.assertIn('"title"', schema_block)
self.assertNotIn('"role"', schema_block, "if entries gain a role key, revisit the join wording")
step3 = _sections(SKILL.read_text(encoding="utf-8")).get("Step 3: Pass 1 — Hard Skill Diff", "")
dedupe = step3[step3.index("**Dedupe.**"): step3.index("2. **Recorded gaps")]
self.assertIn("`title`", dedupe, "the dedupe must name the entry field, title")
self.assertIn("`role`", dedupe, "the dedupe must name the tracker column, role")
self.assertNotIn("company + role", dedupe, "entries have no role field to match on")
def test_step3_handles_blank_fit_rating(self):
"""/outcome creates tracker rows for applications made outside the
workflow, and no rule anywhere fills fit_rating on that path - yet