mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
fix(outcome,interview): glob the full <company>_<role> stem in the CV fallback (#444)
When a tracker row's cv_file/cover_letter_file columns are empty, /outcome and /interview fell back to a company-prefix glob (cv/main_<company>*.tex). /apply names drafts main_<company>_<role><CV_EXT>, so two roles at one company both match that glob. /outcome copied whichever the filesystem returned first into the archive as cv_draft.tex - the file whose stated purpose is to record what was actually submitted - and its own "leave an existing archived file" rule then made the wrong copy permanent. Both fallbacks now glob cv/main_<company>_<role>.* and cover_letters/cover_<company>_<role>.*, deriving the stem by the Subfolder naming rule in documents/README.md rather than restating it, and skip with a note instead of widening the search. Dropping the hardcoded .tex also makes a template registered by /add-template findable. The dot before the extension wildcard matters: a bare trailing * also absorbs a longer role, so ML Engineer and ML Engineer II at one company would collide the same way the company-prefix glob did.
This commit is contained in:
@@ -25,7 +25,7 @@ v1 preps for a **specific application**. Generic no-target practice is out of sc
|
||||
- `job_posting.md` - the exact posting the user applied to
|
||||
- `cv_draft.tex` and `cover_letter.tex` - what was actually submitted. **These are what the interviewer read**; every talking point must be consistent with their claims.
|
||||
- `outcome.md` - the stage reached so far and any recorded feedback from earlier stages. Feedback from stage N is the highest-value input for stage N+1 prep.
|
||||
2. **Fallbacks** (the application may predate `/outcome`): posting via WebFetch on the tracker row's `source` URL, or ask the user to paste it; CV via `cv/main_<company>*.tex` and cover letter via `cover_letters/cover_<company>_*.tex`. State plainly which context is missing rather than guessing - and suggest `/outcome <company>` to build the archive for next time.
|
||||
2. **Fallbacks** (the application may predate `/outcome`): posting via WebFetch on the tracker row's `source` URL, or ask the user to paste it; CV via `cv/main_<company>_<role>.*` and cover letter via `cover_letters/cover_<company>_<role>.*`, deriving `<company>_<role>` by the **Subfolder naming** rule in `documents/README.md`. **Never widen those globs to the company alone**: with two roles at one company it would prep you from the sibling role's documents. State plainly which context is missing rather than guessing - and suggest `/outcome <company>` to build the archive for next time.
|
||||
3. **Ask the user what this interview is** (skip anything `outcome.md` already records): stage (phone screen / technical / case / final round), date, format (phone, video, onsite), and who is interviewing (names and titles, if known).
|
||||
4. **Read the frameworks once** - do not re-read them in later steps:
|
||||
- `.claude/skills/job-application-assistant/07-interview-prep.md`
|
||||
|
||||
@@ -161,7 +161,7 @@ Wait for the user's explicit response before writing anything.
|
||||
|
||||
Create or update `documents/applications/<company>_<role>/`. All content here is personal data - the folder is already gitignored (`documents/applications/**`), so nothing needs redacting.
|
||||
|
||||
1. **`cv_draft.tex` and `cover_letter.tex`** - copy (never move) the submitted files. Locate them via the tracker row's `cv_file`/`cover_letter_file` columns; if those are empty, look for `cv/main_<company>*.tex` and `cover_letters/cover_<company>_*.tex`. If a file already exists in the archive, leave it - the archived version is what was actually submitted. If no draft files exist (application made outside `/apply`), skip with a note.
|
||||
1. **`cv_draft.tex` and `cover_letter.tex`** - copy (never move) the submitted files. Locate them via the tracker row's `cv_file`/`cover_letter_file` columns; if those are empty, look for `cv/main_<company>_<role>.*` and `cover_letters/cover_<company>_<role>.*`, deriving `<company>_<role>` by the **Subfolder naming** rule in `documents/README.md`. **Never widen those globs to the company alone** - two roles at one company both match it, and the first hit wins silently. If a file already exists in the archive, leave it - the archived version is what was actually submitted. If nothing matches (application made outside `/apply`), skip with a note rather than widening the search: a sibling role's CV recorded as what you submitted is worse than no file at all.
|
||||
2. **`job_posting.md`** - if it already exists, leave it. Otherwise try WebFetch on the tracker row's `source` URL and save the posting text, retrying a 403 with browser headers per `.claude/skills/job-application-assistant/09-web-research.md`. If the URL is dead (postings expire fast - this is exactly why the archive matters), ask the user to paste the posting, or write a stub noting the posting is unavailable. **Never reconstruct a posting from memory.**
|
||||
3. **`outcome.md`** - write or update it in exactly the format documented in `documents/README.md`, so `/setup` Path A parses it without special cases:
|
||||
|
||||
|
||||
@@ -47,6 +47,18 @@ per-file diff commands.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`/outcome` and `/interview` no longer confuse two roles at the same company** (#443)
|
||||
(`.claude/commands/outcome.md`, `.claude/commands/interview.md`,
|
||||
`tests/test_apply_records_application.py`) - when a tracker row's `cv_file` /
|
||||
`cover_letter_file` columns are empty, both commands fell back to a company-prefix glob
|
||||
(`cv/main_<company>*.tex`). Two roles at one company both match it, so `/outcome` copied
|
||||
whichever the filesystem returned first into the archive as `cv_draft.tex` - the file whose
|
||||
purpose is to record what was actually submitted - and its own "leave an existing archived
|
||||
file" rule then made the wrong copy permanent. Both fallbacks now glob the full
|
||||
`<company>_<role>` stem, derived by the **Subfolder naming** rule in `documents/README.md`
|
||||
rather than restated, and skip with a note instead of widening the search. Dropping the
|
||||
hardcoded `.tex` also makes a template registered by `/add-template` findable.
|
||||
|
||||
- **`jobnet-search detail` no longer reports an externally hosted ad as not found** (#432) -
|
||||
Jobnet's `/FindJob/JobAdDetails/<id>` returns 404 for ads with `isExternal: true`, so `detail`
|
||||
on an ad `search` had just listed exited 1 with `NOT_FOUND`, and `/scrape` read the posting as
|
||||
|
||||
@@ -11,6 +11,7 @@ byte-identical to /outcome's, which is the entire reason for reusing it.
|
||||
How each reader treats `drafted` is pinned per reader below, because the
|
||||
right answer differs between them.
|
||||
"""
|
||||
import fnmatch
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -385,6 +386,98 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase):
|
||||
self.assertIn(needle, haystack, why)
|
||||
|
||||
|
||||
class FallbackGlobFindsOneRolesDocuments(unittest.TestCase):
|
||||
"""The `cv_file` fallback must select one role's documents, not one company's.
|
||||
|
||||
`/apply` names drafts `cv/main_<company>_<role><CV_EXT>`, so two roles
|
||||
at one company differ only in the role half. When the tracker row's
|
||||
`cv_file`/`cover_letter_file` columns are empty - a row written before
|
||||
#291, added by hand, or by /outcome's own outside-the-workflow path -
|
||||
both readers fall back to a glob. A company-prefix glob matches both
|
||||
roles and the first hit wins silently: /outcome copies it to
|
||||
`cv_draft.tex`, and its own "leave an existing archived file" rule then
|
||||
makes the wrong answer permanent (#443).
|
||||
|
||||
The globs are extracted from the specs rather than restated here, so
|
||||
these tests pin what the specs actually say.
|
||||
"""
|
||||
|
||||
COMPANY = "Acme"
|
||||
ROLES = ("Data Scientist", "ML Engineer", "ML Engineer II")
|
||||
|
||||
CASES = [
|
||||
(OUTCOME, "## Step 3: Archive the Application Materials",
|
||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||
"the archive locator must derive the stem by the one documented rule, "
|
||||
"not invent a second derivation that drifts from it"),
|
||||
(OUTCOME, "## Step 3: Archive the Application Materials",
|
||||
"Never widen those globs to the company alone",
|
||||
"without the prohibition the next edit relaxes the glob when it finds "
|
||||
"no match, which is exactly the wrong-file-recorded-as-submitted case"),
|
||||
(INTERVIEW, "## Step 1: Load the Application Context",
|
||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||
"interview's fallback must resolve the same stem /apply wrote"),
|
||||
(INTERVIEW, "## Step 1: Load the Application Context",
|
||||
"Never widen those globs to the company alone",
|
||||
"prep built from the sibling role's CV is a live-conversation failure"),
|
||||
]
|
||||
|
||||
def test_both_readers_glob_the_full_stem(self):
|
||||
for path, heading, needle, why in self.CASES:
|
||||
with self.subTest(file=path.name, rule=needle):
|
||||
self.assertIn(needle, section(path, heading), why)
|
||||
|
||||
@staticmethod
|
||||
def globs(path, heading):
|
||||
"""The two fallback globs exactly as the spec writes them."""
|
||||
body = section(path, heading)
|
||||
found = re.findall(r"`(cv/main_[^`]+|cover_letters/cover_[^`]+)`", body)
|
||||
return [g for g in found if "*" in g]
|
||||
|
||||
def resolve(self, glob, role):
|
||||
"""Substitute the spec's placeholders the way the reader would."""
|
||||
stem = ArchiveNameIsOnePathComponent.derive(self.COMPANY, role)
|
||||
company = ArchiveNameIsOnePathComponent.derive(self.COMPANY, "").rstrip("_")
|
||||
return glob.replace("<company>_<role>", stem).replace("<company>", company)
|
||||
|
||||
def drafted_files(self, ext=".tex"):
|
||||
"""Exactly what /apply Step 5 leaves in cv/ for two roles at one company."""
|
||||
return [
|
||||
"cv/main_%s%s" % (ArchiveNameIsOnePathComponent.derive(self.COMPANY, r), ext)
|
||||
for r in self.ROLES
|
||||
]
|
||||
|
||||
def test_the_cv_glob_selects_the_row_s_own_role(self):
|
||||
on_disk = self.drafted_files()
|
||||
for path, heading in ((OUTCOME, "## Step 3: Archive the Application Materials"),
|
||||
(INTERVIEW, "## Step 1: Load the Application Context")):
|
||||
cv_glob = next(g for g in self.globs(path, heading) if g.startswith("cv/"))
|
||||
for role, expected in zip(self.ROLES, on_disk):
|
||||
with self.subTest(file=path.name, role=role):
|
||||
hits = fnmatch.filter(on_disk, self.resolve(cv_glob, role))
|
||||
self.assertEqual(
|
||||
hits, [expected],
|
||||
"%s's fallback glob %r matched %r for role %r. A glob that "
|
||||
"matches both roles hands /outcome whichever the filesystem "
|
||||
"returns first, and it archives that as what was submitted."
|
||||
% (path.name, cv_glob, hits, role),
|
||||
)
|
||||
|
||||
def test_the_glob_finds_a_non_tex_template(self):
|
||||
"""`/add-template` makes `.typ` a real output; a hardcoded `.tex` misses it."""
|
||||
on_disk = self.drafted_files(ext=".typ")
|
||||
cv_glob = next(
|
||||
g for g in self.globs(OUTCOME, "## Step 3: Archive the Application Materials")
|
||||
if g.startswith("cv/")
|
||||
)
|
||||
hits = fnmatch.filter(on_disk, self.resolve(cv_glob, self.ROLES[0]))
|
||||
self.assertEqual(
|
||||
hits, [on_disk[0]],
|
||||
"the fallback hardcodes an extension, so a template registered by "
|
||||
"/add-template is invisible to it and /outcome archives nothing",
|
||||
)
|
||||
|
||||
|
||||
class ArchiveNameIsOnePathComponent(unittest.TestCase):
|
||||
"""`<company>_<role>` must derive a single path component.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user