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>
Backend: new GET /analytics/applications-per-job/fetch (analytics.view) counting
inbox + manual-upload applications per job on the same basis as the funnel, with
zero-application open reqs included. Per-job group-bys added to Inbox_Messages,
Manual_UPLOAD_CANDIDATE; JobPosts.list_open_reqs for the zero-fill.
Frontend: Dashboard rewritten as a filterable summary (Week/Month/Quarter/Year +
department) - My Tasks, Today's Schedule and Quick Actions removed; 8 KPI tiles
(Hires and Offers Sent now shown); Applications per Job hero row-list; pipeline
card refed from /analytics/funnel so it obeys filters; Offer Book, Needs
Attention and Recent Activity cards added. ChartCard and time ranges extracted
to shared modules used by both Dashboard and Analytics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploy to S3 / deploy (push) Successful in 37sDetails
The banked PDF now lives IN the database (new cv_bank_files table,
manual migration 010, auto-applied at startup) instead of the container
filesystem, so production redeploys cannot lose a stored CV; upload
writes row + bytes in one commit and creates no disk file at all. New
GET /candidate/cv-bank/file serves the bytes for both download and the
in-app preview.
Per user request, banking a CV with a detectable email also creates or
reactivates the candidate account (same pattern as manual add, no setup
email), so banked people appear on the Candidates screen; a CV without
an email still banks fine, account-less. Existing bank rows were
backfilled locally.
E2E-verified: upload -> bank row + DB bytes + zero disk files, preview
and download served from the DB, candidate visible on the Candidates
screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploy to S3 / deploy (push) Successful in 36sDetails
Storing a CV without a job no longer rides the cv_upload pipeline, which
created a candidate user account and an inbox row — stored CVs were
leaking into the Candidates screen. New cv-bank endpoints instead write
apply_via=cv_bank rows in manual_upload_candidate (nullable user/job
FKs): file + parsed text only, no account, no inbox entry, no scoring,
and email is optional (captured when the CV contains one). The CV Import
screen now shows the bank itself below the dropzone — browse, download
(existing /documents/download route) and delete.
E2E-verified: uploads land as BANKED rows with user_id NULL, zero new
accounts or inbox rows, UI delete works, Candidates screen unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploy to S3 / deploy (push) Successful in 36sDetails
The job picker gains a No job / store in CV bank option: files upload
one-per-request through the existing POST /candidate/cv_upload pipeline
(email auto-detected from the CV, saved as an UNASSIGNED inbox item with
background job suggestions) instead of being scored. Queue rows show
Stored plus the detected email; a CV with no detectable email fails
alone with a clear message. In this mode the scored grid gives way to a
panel linking Job Matching and the Inbox, where stored CVs are browsed
and later assigned. Also guarded the matcher enqueue in ingest_upload:
with the broker down the upload used to 500 after the row was inserted.
E2E-verified: two CVs stored (emails detected), rows visible in DB with
assigned_job_post_id NULL, both surfaced in Job Matching and the Inbox.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploy to S3 / deploy (push) Successful in 31sDetails
Production containers have ephemeral filesystems, so the disk-backed
image from the previous commit would vanish on redeploy. New
job_post_images table (bytea, PK = job_posts FK so re-upload replaces),
created everywhere by manual migration 009 which run_manual_sql applies
automatically at startup — prod boots with DB_AUTOGENERATE=false and
never autogenerates tables. Upload/fetch endpoints unchanged for the
frontend; fetch now serves bytes from the row. The one locally stored
disk image was imported into the table and backend/uploads removed.
E2E re-verified: create-with-image 200, fetch 200, cover renders.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploy to S3 / deploy (push) Successful in 32sDetails
The create modal collected an image but dropped it. Now POST
/job/image/upload stores it on disk keyed by the post id (uuid-validated,
5 MB / png-jpg-webp-gif, replace-on-reupload; no DB migration) and GET
/job/image/fetch serves it. The create flow uploads right after the row
exists, image failure downgrades to a toast instead of failing the create,
and Job Details renders the cover via an authorized blob fetch.
E2E-verified: upload 200, fetch 200, cover renders in Job Details.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploy to S3 / deploy (push) Successful in 36sDetails
Dropped from the create and edit modals, the detail drawer, the frontend
job mapper, and the composed publish text (which otherwise printed
Salary: Anonymous on every post). Backend model/API still tolerate the
column, so older clients and stored rows are unaffected.
UI-verified live: modal has no salary field, create POST carries no
salary key, cover-image upload previews and the post lands successfully.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploy to S3 / deploy (push) Successful in 37sDetails
Backend: new candidate_forms domain (requisition, interview analysis,
cultural fit) with XOR inbox/manual keys, server-recomputed section
averages and combined summary, INTERVIEW-stage gate (409), history
events, INTERVIEWS_* permissions + 008 RBAC seed; offers table gains
the seven Annexure-J fields.
Frontend: Forms tab in the candidate profile (paper-exact labels from
/forms/definitions, rating tables, score summary tiles, completion
dots); profile converted to a full page at /candidate/:userId opened
from Candidates, Talent Pool and Pipeline; live Advance Stage now calls
PATCH /candidate/stage; workflow-ordered tabs; responsive pass verified
by headless-Edge screenshots at 375-2400px; Stars import crash fix in
Interviews; Matching tab strip wraps on phones.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dashboard/chart audit (senior-analyst pass over the live app):
- Pipeline bars were all 0% while the doughnut showed candidates: the
percentage base was the first stage count (PROCESS, empty). Bars are
now each stage share of the active total, REJECTED is excluded
(matching the Analytics card), and stages sort in pipeline order.
- KPI trend chips no longer show a green up-arrow beside an empty delta;
for lower-is-better metrics (time to hire, cost per hire) the colour
tracks goodness while the arrow tracks the data direction.
- Closed Jobs tile no longer adds hires to closed requisitions and no
longer hardcodes an up arrow; Offers Accepted drops the hires-series
sparkline that plotted the wrong metric.
- charts.js: integer-friendly Y ticks (no more 0,1,1,2,2), edge-hugging
X labels (last month was clipped), and label thinning on packed axes.
Find Talent: nav item and page title renamed from Talent to match the
action button and to distinguish it from Talent Pool.
Already-applied matching (Find Talent x ATS):
- linkedin_utils.py: shared /in/<slug> extraction that survives PDF
mangling (percent-escapes, no scheme, trailing punctuation, /pub/).
- inbox_messages.linkedin_slug + manual_upload_candidate.linkedin_slug
(indexed; empty = scanned-none, NULL = unscanned) written as CVs are
processed and lazily backfilled in bounded batches at fetch time.
- talent/matching.py annotates profile payloads with already_applied
(source, status, job, same_job, applications); profile cards and the
detail modal show a green Already-applied / amber In-ATS-other-job
chip.
Verified live: seeded CV mention matched its sourced profile with zero
false positives across 50 real Apify profiles; 57 backend tests and the
frontend build + smoke suites pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Builds the four remaining analytics gaps from the architecture plan:
- Report library (REQ-ANL-03): new backend/reports module - saved reports
as parameterisations of the governed analytics queries (never free-form
SQL), rolling window_days filters, recorded runs, CSV export via
Content-Disposition. Reports screen gains the library card with
create/run/export/delete plus a results modal.
- Natural-language analytics (REQ-ANL-05, ADR-0010): POST /analytics/ask
maps a question onto one whitelisted intent, runs the same governed
query the dashboard uses, then narrates the numbers. Ask Analytics card
on the Analytics screen; LLM outages 503 without touching the charts.
- Time-to-hire baseline (REQ-ANL-08): KPIs surface tth_baseline_* from
org setting analytics.tth_baseline ({days,source}); not hardcoded
per OPEN-12. analytics added to org-settings categories. Reports TTH
card shows the delta when set.
- Source spend (REQ-ANL-09 cost side): hiring costs can be tagged with a
source channel; source performance returns tagged spend and
cost-per-application, including spend-only rows for channels with zero
applications (wasted spend must stay visible). Log-cost modal and
Source Performance table on Reports; untagged spend stays
cost-per-hire only.
Verified live: migration autogenerated and applied (saved_reports,
report_runs, hiring_costs.source_channel_id), 50 backend tests, vite
build + smoke/token/theme suites, and a headless-browser drive of both
screens end to end.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Adjusted `VITE_API_BASE` in Dockerfile and docker-compose.yml to allow same-origin requests, enhancing compatibility with nginx proxy settings.
- Increased the `top` query limit in `app.py` to 500 to accommodate frontend requirements while ensuring consistency across job fetching in `Jobs.jsx` and `Managers.jsx`.
- Updated nginx configuration to properly proxy API requests, preventing incorrect responses for job-related endpoints.
These changes streamline the interaction between the frontend and backend, ensuring a smoother user experience when fetching job data.
- Added a new `CandidateHistory` model to maintain an append-only audit log for candidate actions.
- Introduced `HistoryRecorder` functionality to log significant events across various modules, including interviews, feedback, and notes.
- Updated existing methods to include `current_user` for tracking who performed actions.
- Enhanced the `EMAIL_URL` configuration in `docker-compose.yml` to allow for environment variable overrides.
- Updated frontend components to support fetching and displaying candidate history.
This commit improves the application's ability to track changes and actions related to candidates, enhancing accountability and transparency.
- Updated the interview model to include `graph_event_id` and `web_link` for calendar integration.
- Added methods for setting calendar events and retrieving job titles associated with interviews.
- Modified serializers to include new fields in the interview response.
- Enhanced the job post model to include applicant counts.
- Updated frontend components to handle new fields and improve user experience with calendar event management.
- Introduced image upload functionality in job forms with validation for file types and sizes.
- Improved dashboard and calendar UI for better data presentation.
This commit streamlines interview scheduling and job management processes, enhancing overall application functionality.