Deploy to S3 / checks (push) Failing after 2m24sDetails
Deploy to S3 / deploy (push) Has been skippedDetails
The bank used to be write-only: a CV uploaded with no job carried only its
full text, so nothing could search or rank it. Now the employment agent's
extraction (title, company, education, plus new skills and years_experience,
both clamped to what the resume actually states) is stored on the row, and
the bank is ranked against a job the moment that job opens.
- New CV Bank screen at /cvbank replaces Talent Pool; the inline bank card
moves out of CV Import. One table, two populations: speculative uploads,
and silver medalists (rejected applicants scoring >= CV_BANK_SILVER_FLOOR,
read live from their application rather than copied).
- matching/ranking.py: the tier-1 keyword ranker moves out of
talent/plugins.py so Find Talent and the bank share one implementation;
talent/plugins.py re-exports it and its numbers are unchanged.
- Taskiq tasks in job.candidate.bank_tasks: backfill profiles for CVs
banked before extraction existed, and rank the bank when a job opens so
recruiters are told about matches above CV_BANK_SUGGEST_THRESHOLD.
Retention (CV_BANK_RETENTION_MONTHS) is stamped on the row at upload; the
sweep flags expired rows and never deletes.
- Migrations 029 (bank profile columns) and 030 (per-job bank matches).
- Routes: POST /candidate/cv-bank/score, GET /candidate/cv-bank/suggestions.
- README: The CV Bank, plus the retention and deletion policy.
Also in this change:
- Inbox, Sheet Forms: has_linkedin / has_resume filters, tri-valued so
"no link" is a real filter and NULL rows are kept in it; tab badge counts
now narrow with the list and the search box.
- Hiring-manager candidate rows carry the ATS score and band.
- Tests: analytics dashboard merge logic, employment extraction clamps,
form-data filters, manager candidate serializer, CV Bank mapper.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Added methods to retrieve application history by email across various sources (inbox, manual uploads, form data).
- Introduced new serializers for application history items and overall history.
- Updated candidate and inbox views to include application history in responses.
- Enhanced frontend components to display reapplication badges and previous application details.
- Adjusted API endpoints to support fetching application history based on email input.
Reported as "LinkedIn is not showing though the resume has it". The LinkedIn
was never the problem.
Traced on the live application (Mohammad Raza, inbox row 2f81cebc). Its stored
resume_text is 4,555 characters over 2,278 lines, and every one of those lines
is exactly one character long. The CV really does say
LinkedIn:linkedin.com/in/mohammad-raza-digital-marketer
but it is stored as forty separate lines, so nothing that looks for a substring
can find it. Not a link annotation, not an image, not OCR: pypdf's default mode
breaks after every glyph on PDFs whose author positioned each one separately,
which design tools do routinely.
It survived review because a model reads that text fine. The candidate was
classified, matched and scored normally. What fails, silently, is every check
that asks "does this string appear in the resume":
- slugs_from_text finds no profile, so linkedin_slug is stored empty
- _clean_skills drops every skill, since each must appear in the text
- the company and education clamps drop theirs for the same reason
- verify_matched_keywords drops every matched keyword in the ATS engine
despace_line could not help: it rebuilds glyphs padded *within* a line, and
here there is nothing left on a line to rebuild.
is_glyph_fragmented measures the giveaway — the share of non-empty lines that
are a single character — and extract_pdf_text re-extracts with pypdf's layout
mode when it trips. Layout mode is the fallback, never the default: it is
slower and pads ordinary documents with alignment whitespace, so a CV that
extracts cleanly today is untouched. The fallback is checked before it is
trusted; fragmented text still scores a candidate, empty text fails them.
Both extractors had the defect, so the helpers live in app/services/pdf.py,
which owns PDF handling and is already imported by the recruiting path.
Measured against that real CV, before and after:
slugs_from_text [] -> ['mohammad-raza-digital-marketer']
profile_url_from_text None -> https://www.linkedin.com/in/...
lines 2278 -> 61
single-char lines 2278 -> 0
'performance' found False -> True
'google ads' found False -> True
Existing rows keep their broken text; extraction runs at ingest. Re-running
the match on affected rows is what backfills them.
.gitignore had `tests/**` twice and `/backend/tests/**` once. Both suites are
tracked and both run in CI, so the rules were inert for existing files and did
nothing but swallow new ones — this test was invisible to `git status` until
they went. That is also why they are removed rather than negated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploy to S3 / deploy (push) Successful in 34sDetails
The recruiter table was user-centric (GET /candidate/fetch/users), so it could
only ever render account fields - name, email, created date. Everything a
recruiter actually triages on lives on the application, not the user.
Point the table at GET /candidate/fetch and map application rows through a new
toApplicationListView, adding Job, ATS (score + band chip), Stage and Recruiter
columns. Stage and band become real filters; the dead Department facet is gone.
Manual uploads came back unscored because the list path never joined the ATS
results, so attach scores there and expose ai_score/recommendation from the
manager serializer, deriving the band from the score when the model omitted it.
Co-authored-by: Cursor <cursoragent@cursor.com>
The recruiter table was user-centric (GET /candidate/fetch/users), so it could
only ever render account fields - name, email, created date. Everything a
recruiter actually triages on lives on the application, not the user.
Point the table at GET /candidate/fetch and map application rows through a new
toApplicationListView, adding Job, ATS (score + band chip), Stage and Recruiter
columns. Stage and band become real filters; the dead Department facet is gone.
Manual uploads came back unscored because the list path never joined the ATS
results, so attach scores there and expose ai_score/recommendation from the
manager serializer, deriving the band from the score when the model omitted it.
Co-authored-by: Cursor <cursoragent@cursor.com>
The S3 artifact is 32.9 MiB compressed. Every other project's zip in that
bucket is under 1 MiB. The difference is frontend/node_modules, which is
committed to this repo and so was swept into every upload.
Earlier I left it in because nothing in the repo says what consumes the
bucket object, and if that side ran the app without installing dependencies,
dropping it would have broken the deploy. That is now answered rather than
assumed. Traced on the instance:
CodeDeploy extracts to /opt/codedeploy-extracted-5, the AfterInstall hook
copies the tree to /home/ec2-user/utopia-ai-hr-ats-portal-deployment-group,
then runs `docker compose --env-file ./backend/.env up -d --build`.
Eleven containers come up and the only Node one is hrms-frontend, built from
frontend/Dockerfile, which does `npm ci` against the lockfile. On top of that
frontend/.dockerignore excludes node_modules/ from the build context outright,
so even the copy that arrived could not have been read. It was carried across
the wire on every merge to main and then discarded unread.
The exclusion patterns were verified rather than trusted: zip -r with
-x on a synthetic tree in a temp dir on the box, since zip is not available
locally. That run also confirmed the previous commit's other fix — the
original `-x ".gitignore/*"` really did fail to match the file, and
`-x ".gitignore"` matches it.
Not done here, deliberately: node_modules is still tracked in git, which is
why it was in the artifact in the first place. Untracking it deletes 5,230
files from every other contributor's working tree on their next pull, across
thirteen active branches, and needs a heads-up rather than a surprise.
Two things found while reading the deploy script, neither touched:
- It copies with `cp -r` and never deletes, so a file removed from the repo
survives on the server indefinitely. Switching to a delete-on-sync would
risk backend/.env, which the script deliberately preserves.
- It re-downloads the latest docker compose and buildx from GitHub on every
single deploy, unpinned, as root.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pipeline deployed whatever was on main without checking any of it. No
build, no tests, no lint. A frontend that failed to compile zipped and
uploaded exactly like a working one, and the first sign of trouble would have
been the running site.
The checks now gate the deploy. `deploy` needs a `checks` job, so a red main
never reaches the bucket. A second workflow runs the same script on branches
and pull requests, so the answer arrives before the merge rather than after
it. main is excluded there because the deploy workflow already covers it.
Both call scripts/ci-checks.sh rather than inlining the commands twice, which
is the only way the two cannot drift into disagreeing about what passing means.
What gates, and what deliberately does not:
- frontend: npm ci then npm run verify. All four suites are jsdom, so no
browser is needed on the runner. Node 22 matches frontend/Dockerfile.
- bulk-ats: ruff check, ruff format --check, mypy, pytest. Scoped to app and
tests. Repo-wide, ruff reports 926 errors and would reformat 126 files —
the backend is written to another style and demanding a rewrite of it is
not this change's business. The scoped set is clean today.
- backend/tests: passes and gates, minus test_candidate_forms.py and
test_employment_agent.py. Those nine failures predate this change and are
unrelated to it. They are named in the script, not silently skipped, so
the exclusion stays visible and someone can delete the two lines.
Two dead lines in the deploy, found while reading it:
- The step named "Configure AWS credentials" set three variables and then
only echoed a message. `env:` is scoped to its own step, so the values were
discarded before anything could use them. It did nothing while reading as
though credentials were configured globally. Removed; the upload step sets
them where they are actually used.
- `-x ".gitignore/*"` excludes a *directory* named .gitignore, which does not
exist, so the file was never excluded. Now `-x ".gitignore"`.
The zip still ships frontend/node_modules, about 90 MB and most of the
artifact. Left alone on purpose: nothing in this repo says what unpacks the
object — there is no appspec and no deploy script here — so if that side runs
without installing dependencies, dropping it would break the deploy. The
workflow now carries a comment saying so and the one-line change to make once
that is confirmed.
.gitattributes pins *.sh to LF. The script is bash on a Linux runner; committed
with CRLF from a machine with core.autocrlf=false it would fail on line one
with `$'\r': command not found`, which reads as a broken pipeline rather than a
line-ending problem.
Verified by running scripts/ci-checks.sh locally end to end, exit 0. Only the
npm ci line was skipped, because it would rewrite the 5,230 committed
node_modules files; frontend/Dockerfile already builds that way. Both workflow
files were parsed and their job graph inspected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A CV goes through four steps before it is usable: the intake classifier
decides it is an application, the PDF text is extracted, an agent matches it
against job posts, and only then is an ATS score written. A PDF with no
readable text stops at step two. It is never matched and never scored.
The list said nothing about any of this. The parse badge existed only in the
detail pane, so a dead row looked exactly like a good one until you clicked
it. Across eleven hundred applications that is not discoverable, and the
candidate whose file happened not to open is simply lost.
The row now carries the badge, with two deliberate limits:
- Exceptions only. Parsing, Failed and Pending show; Parsed shows nothing.
A green chip on the overwhelming majority of rows is decoration that
buries the two states worth spotting, and no badge already reads as fine.
The detail pane still shows every state, Parsed included, because there
the row is the whole subject.
- Email only. Sheet Form applicants have no mailbox attachment to parse.
The guard is redundant against today's mapFormRow, which sets no
resumeStatus at all, and is kept as a tripwire rather than a load-bearing
check — the comment says so rather than overclaiming.
"Failed" on its own tells a recruiter nothing to do next, so each state got a
plain-language tooltip saying what happened and that the attachment is still
there to open by hand. That needed Badge to forward `title`, which it did not.
The colour rule was duplicated inline in the detail pane; it is now one
helper both halves call, so the same state cannot paint two colours.
Four assertions added to inbox-loading.test.mjs. Three are proven sensitive
by reverting the change: removing the badge fails the label and tooltip
checks, and badging every state fails the exceptions-only check. The fourth,
that form rows stay unlabelled, passes even without the guard and is marked
in the test as a tripwire, not proof.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>