CV Bank: replace Talent Pool with a searchable, ranked bank of stored CVs
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>pull/73/head^2
parent
05ca7bce8f
commit
5b29e2b592
|
|
@ -112,6 +112,18 @@ TASKIQ_IDLE_TIMEOUT_MS=600000
|
||||||
MANUAL_UPLOAD_TO_ADDRESS=manual-cv-upload@hr-ats.local
|
MANUAL_UPLOAD_TO_ADDRESS=manual-cv-upload@hr-ats.local
|
||||||
APP_VERSION=dev
|
APP_VERSION=dev
|
||||||
|
|
||||||
|
# CV Bank. Retention is stamped on the row at upload, so raising this later does
|
||||||
|
# not extend CVs already taken in. The sweep flags expired entries; it never
|
||||||
|
# deletes. Leave the notify address blank to keep the log line only.
|
||||||
|
CV_BANK_RETENTION_MONTHS=24
|
||||||
|
CV_BANK_RETENTION_CRON=0 3 * * *
|
||||||
|
CV_BANK_RETENTION_NOTIFY_EMAIL=
|
||||||
|
# Tier-1 rank (free keyword overlap) a banked CV must clear to notify a recruiter
|
||||||
|
# when a job opens; and the ATS score a rejected applicant needs to count as a
|
||||||
|
# silver medalist.
|
||||||
|
CV_BANK_SUGGEST_THRESHOLD=55
|
||||||
|
CV_BANK_SILVER_FLOOR=60
|
||||||
|
|
||||||
# Compose host ports (docker compose --env-file ./backend/.env …).
|
# Compose host ports (docker compose --env-file ./backend/.env …).
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
|
|
||||||
|
|
@ -593,6 +593,9 @@ Taskiq over **Redis Streams**, with a result backend and a Redis-backed schedule
|
||||||
| `inbox.match_message` (CV broker) | enqueued by `/candidate/cv_upload` onto the `cv_upload` stream | Same matcher as above; isolated so uploads never sit behind `/email/fetch` backlog |
|
| `inbox.match_message` (CV broker) | enqueued by `/candidate/cv_upload` onto the `cv_upload` stream | Same matcher as above; isolated so uploads never sit behind `/email/fetch` backlog |
|
||||||
| `inbox.score_message` | enqueued by `PATCH /inbox/{id}/assign-job-post` onto the `inbox` stream | ATS-score one message against one job. Idempotent — a completed (message, job) pair returns `already_scored` without paying for a second call |
|
| `inbox.score_message` | enqueued by `PATCH /inbox/{id}/assign-job-post` onto the `inbox` stream | ATS-score one message against one job. Idempotent — a completed (message, job) pair returns `already_scored` without paying for a second call |
|
||||||
| `inbox.sync_read_status` | cron, `EMAIL_SYNC_CRON` (default every minute) | Pull read-status deltas from the Email API and apply them |
|
| `inbox.sync_read_status` | cron, `EMAIL_SYNC_CRON` (default every minute) | Pull read-status deltas from the Email API and apply them |
|
||||||
|
| `cvbank.rank_for_job` | enqueued by `POST /job/post-job` after `insert_job_post` | Tier-1 rank every banked CV against the new job into `cv_bank_matches`, then notify the recruiter if any clears `CV_BANK_SUGGEST_THRESHOLD` |
|
||||||
|
| `cvbank.backfill_profiles` | manual, one-off | Extract skills/title/company/years for CVs banked before migration 029. Re-runnable; returns `remaining` so it can be enqueued in batches |
|
||||||
|
| `cvbank.sweep_expired` | cron, `CV_BANK_RETENTION_CRON` (default `0 3 * * *`) | Flag bank CVs past `bank_expires_at` for review. Flags only — it never deletes |
|
||||||
| `ping` | manual | Framework smoke test |
|
| `ping` | manual | Framework smoke test |
|
||||||
|
|
||||||
**Auto-scoring never fails a match.** `match_inbox_message` commits the agent result first,
|
**Auto-scoring never fails a match.** `match_inbox_message` commits the agent result first,
|
||||||
|
|
@ -616,6 +619,55 @@ un-reading a mail in Outlook no longer propagates here. `sync_read_status` holds
|
||||||
(`inbox:sync_read_status:lock`, 300s TTL) so overlapping cron ticks cannot double-run, and
|
(`inbox:sync_read_status:lock`, 300s TTL) so overlapping cron ticks cannot double-run, and
|
||||||
pages at most 10 rounds per tick.
|
pages at most 10 rounds per tick.
|
||||||
|
|
||||||
|
**Ranking on job creation is fire-and-forget.** `JobPost._rank_cv_bank` swallows broker
|
||||||
|
errors: the job post is already committed, and Redis being down must not turn a successful
|
||||||
|
creation into a 500. The CV Bank screen recomputes any missing rank on read, so a dropped
|
||||||
|
enqueue degrades to a slower page rather than a wrong one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The CV Bank
|
||||||
|
|
||||||
|
Two populations behind one screen and one endpoint (`GET /candidate/cv-bank/fetch`):
|
||||||
|
|
||||||
|
| Source | Where it lives | How it got there |
|
||||||
|
|---|---|---|
|
||||||
|
| `speculative` | `manual_upload_candidate` with `apply_via='cv_bank'`, `job_post_id IS NULL` | `POST /candidate/cv-bank/upload` — a CV with no job |
|
||||||
|
| `silver_medalist` | `inbox_messages` + `ats_results`, read live | Applied, scored at or above `CV_BANK_SILVER_FLOOR`, `application_status='REJECTED'` |
|
||||||
|
|
||||||
|
Silver medalists are a **union query, not a copy**. The application rows keep being the
|
||||||
|
source of truth, so there is no sync to get wrong. Only `REJECTED` qualifies — `CLOSED` is
|
||||||
|
the ingest default for unprocessed mail, and treating it as a rejection would tip the whole
|
||||||
|
unread inbox into the bank.
|
||||||
|
|
||||||
|
**Two-tier matching.** `matching/ranking.py::rank_profile` is deterministic keyword overlap,
|
||||||
|
free, and runs over the entire bank whenever a job opens. The real ATS score costs money and
|
||||||
|
runs only from `POST /candidate/cv-bank/score`, per row, on the ones a recruiter picks. The
|
||||||
|
UI draws them differently on purpose — a rank is not an assessment. The same function backs
|
||||||
|
Find Talent (`talent/plugins.py` re-exports it as `relevance_score`) so the two cannot drift.
|
||||||
|
|
||||||
|
**Extraction is what makes the bank usable.** `run_employment_agent` returns skills, years,
|
||||||
|
title, company, education and phone; before migration 029 the bank stored only `full_text`
|
||||||
|
and could not be searched or ranked at all.
|
||||||
|
|
||||||
|
### Retention and deletion
|
||||||
|
|
||||||
|
A banked CV is personal data held with no job to justify it, so it is held for a stated
|
||||||
|
period rather than indefinitely.
|
||||||
|
|
||||||
|
- `bank_expires_at` is stamped **at upload** from `CV_BANK_RETENTION_MONTHS` (default 24).
|
||||||
|
Stamping on the row rather than computing on read means changing the setting later cannot
|
||||||
|
silently extend CVs already taken in.
|
||||||
|
- Expired rows are excluded from `list_bank_for_ranking`, so an expired CV is never put in
|
||||||
|
front of a recruiter.
|
||||||
|
- `cvbank.sweep_expired` runs nightly and **flags, never deletes.** A misconfigured window
|
||||||
|
would otherwise destroy the entire bank on one cron tick, and a resume someone sent us is
|
||||||
|
not something to drop on a timer with no record. Set
|
||||||
|
`CV_BANK_RETENTION_NOTIFY_EMAIL` to have the sweep raise an in-app notification.
|
||||||
|
- Deletion is a human action: `DELETE /candidate/cv-bank/delete` hard-deletes the row and
|
||||||
|
its bytes (`cv_bank_files` cascades) and removes the S3 object. It refuses rows that
|
||||||
|
already have a `job_post_id` — those are applications, not bank entries.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The matching agent
|
## The matching agent
|
||||||
|
|
@ -958,6 +1010,16 @@ own keys with `os.getenv` from the same file.
|
||||||
| `MANUAL_UPLOAD_TO_ADDRESS` | `manual-cv-upload@hr-ats.local` — To address stamped on synthetic inbox rows so source resolves to `Manual CV Upload` |
|
| `MANUAL_UPLOAD_TO_ADDRESS` | `manual-cv-upload@hr-ats.local` — To address stamped on synthetic inbox rows so source resolves to `Manual CV Upload` |
|
||||||
| `APP_VERSION` | `dev` |
|
| `APP_VERSION` | `dev` |
|
||||||
|
|
||||||
|
### CV Bank
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `CV_BANK_RETENTION_MONTHS` | `24` | Stamped onto `bank_expires_at` at upload, so a later change cannot extend CVs already taken in |
|
||||||
|
| `CV_BANK_RETENTION_CRON` | `0 3 * * *` | `cvbank.sweep_expired` schedule |
|
||||||
|
| `CV_BANK_RETENTION_NOTIFY_EMAIL` | — | Recipient of the expiry-review notification; blank disables it (the log line is still written) |
|
||||||
|
| `CV_BANK_SUGGEST_THRESHOLD` | `55` | Tier-1 rank a banked CV must clear before the recruiter is notified on job creation |
|
||||||
|
| `CV_BANK_SILVER_FLOOR` | `60` | Minimum ATS score for a rejected applicant to appear as a silver medalist |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
@ -990,7 +1052,8 @@ LLM failures are logged and skipped; the API still comes up.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
taskiq worker taskiq_management.broker_setup:broker \
|
taskiq worker taskiq_management.broker_setup:broker \
|
||||||
inbox.tasks inbox.sync_tasks taskiq_management.tasks g_sheet.tasks
|
inbox.tasks inbox.sync_tasks taskiq_management.tasks g_sheet.tasks \
|
||||||
|
job.candidate.bank_tasks
|
||||||
```
|
```
|
||||||
|
|
||||||
**CV-upload worker** (isolated stream for manual uploads):
|
**CV-upload worker** (isolated stream for manual uploads):
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,57 @@ def _clean_phone(value,resume_text):
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_skills(value,resume_text):
|
||||||
|
"""Keep only skills the resume actually contains, deduplicated, capped at 30.
|
||||||
|
|
||||||
|
Same discipline as the company/education clamps: the model is asked for the
|
||||||
|
resume's own spelling, so anything absent from the text is an invention. A
|
||||||
|
skill chip is read as "this is in the CV", and the bank filters on it.
|
||||||
|
|
||||||
|
Deduplication runs BEFORE the ceiling so a model that returns 31 near-
|
||||||
|
duplicates collapses under the limit instead of losing real skills.
|
||||||
|
"""
|
||||||
|
if not isinstance(value,list):
|
||||||
|
return []
|
||||||
|
haystack=(resume_text or "").lower()
|
||||||
|
kept=[]
|
||||||
|
seen=set()
|
||||||
|
for entry in value:
|
||||||
|
if not isinstance(entry,str):
|
||||||
|
continue
|
||||||
|
text=entry.strip()
|
||||||
|
if not text or len(text)>60:
|
||||||
|
continue
|
||||||
|
lowered=text.lower()
|
||||||
|
if lowered in seen:
|
||||||
|
continue
|
||||||
|
if haystack and lowered not in haystack:
|
||||||
|
continue
|
||||||
|
seen.add(lowered)
|
||||||
|
kept.append(text)
|
||||||
|
return kept[:30]
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_years(value,resume_text):
|
||||||
|
"""Whole years of experience, bounded 0-60. Anything else is None.
|
||||||
|
|
||||||
|
Seniority language is not a duration, so an unparseable value has to read
|
||||||
|
as "unknown" rather than 0 — 0 would sort as a junior candidate.
|
||||||
|
"""
|
||||||
|
if isinstance(value,bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value,(int,float)):
|
||||||
|
years=int(value)
|
||||||
|
elif isinstance(value,str):
|
||||||
|
digits=re.search(r"\d+",value)
|
||||||
|
if not digits:
|
||||||
|
return None
|
||||||
|
years=int(digits.group())
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return years if 0<=years<=60 else None
|
||||||
|
|
||||||
|
|
||||||
def prefer_extracted_phone(func):
|
def prefer_extracted_phone(func):
|
||||||
"""Merge CV regex phone with the LLM value; keep the longer complete number."""
|
"""Merge CV regex phone with the LLM value; keep the longer complete number."""
|
||||||
|
|
||||||
|
|
@ -118,6 +169,8 @@ clamp_company_to_resume=clamp_in_resume("current_employment",NO_COMPANY)
|
||||||
clamp_education_to_resume=clamp_in_resume("education",EDUCATION)
|
clamp_education_to_resume=clamp_in_resume("education",EDUCATION)
|
||||||
clamp_linkedin_url=clamp_field("linkedin_url",_clean_linkedin)
|
clamp_linkedin_url=clamp_field("linkedin_url",_clean_linkedin)
|
||||||
clamp_phone=clamp_field("phone",_clean_phone)
|
clamp_phone=clamp_field("phone",_clean_phone)
|
||||||
|
clamp_skills=clamp_field("skills",_clean_skills)
|
||||||
|
clamp_years_experience=clamp_field("years_experience",_clean_years)
|
||||||
|
|
||||||
|
|
||||||
@require_json_object
|
@require_json_object
|
||||||
|
|
@ -126,8 +179,16 @@ clamp_phone=clamp_field("phone",_clean_phone)
|
||||||
@clamp_linkedin_url
|
@clamp_linkedin_url
|
||||||
@prefer_extracted_phone
|
@prefer_extracted_phone
|
||||||
@clamp_phone
|
@clamp_phone
|
||||||
|
@clamp_skills
|
||||||
|
@clamp_years_experience
|
||||||
def parse_employment_response(data,resume_text=""):
|
def parse_employment_response(data,resume_text=""):
|
||||||
"""Pull company, education, title, linkedin_url, and phone from the agent JSON."""
|
"""Pull company, education, title, linkedin_url, phone, skills, and years
|
||||||
|
from the agent JSON.
|
||||||
|
|
||||||
|
skills and years_experience default to []/None when the key is absent, so a
|
||||||
|
model reply predating the extended prompt still parses — the inbox match
|
||||||
|
path reads the other five keys and must not break on a partial response.
|
||||||
|
"""
|
||||||
def as_str(key):
|
def as_str(key):
|
||||||
value=data.get(key)
|
value=data.get(key)
|
||||||
return value.strip() if isinstance(value,str) else ""
|
return value.strip() if isinstance(value,str) else ""
|
||||||
|
|
@ -137,4 +198,6 @@ def parse_employment_response(data,resume_text=""):
|
||||||
"current_title":as_str("current_title"),
|
"current_title":as_str("current_title"),
|
||||||
"linkedin_url":as_str("linkedin_url"),
|
"linkedin_url":as_str("linkedin_url"),
|
||||||
"phone":as_str("phone"),
|
"phone":as_str("phone"),
|
||||||
|
"skills":data.get("skills") if isinstance(data.get("skills"),list) else [],
|
||||||
|
"years_experience":data.get("years_experience"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ async def run_employment_agent(*,resume_text=""):
|
||||||
"current_title":CURRENT_TITLE,
|
"current_title":CURRENT_TITLE,
|
||||||
"linkedin_url":None,
|
"linkedin_url":None,
|
||||||
"phone":None,
|
"phone":None,
|
||||||
|
"skills":[],
|
||||||
|
"years_experience":None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data=await llm_call(prompt(),user_prompt(text),json_mode=True)
|
data=await llm_call(prompt(),user_prompt(text),json_mode=True)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ def prompt():
|
||||||
|
|
||||||
You are given CV/resume text. Identify the candidate's CURRENT employer company
|
You are given CV/resume text. Identify the candidate's CURRENT employer company
|
||||||
name, their education (degree / school), their current job title, their
|
name, their education (degree / school), their current job title, their
|
||||||
LinkedIn profile URL, and their phone number when present.
|
LinkedIn profile URL, their phone number, their skills, and their total years
|
||||||
|
of professional experience, when present.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Return only the company name that appears in the resume text for the ongoing / most recent role.
|
- Return only the company name that appears in the resume text for the ongoing / most recent role.
|
||||||
|
|
@ -32,6 +33,19 @@ Rules:
|
||||||
- Do not invent education. If none is mentioned, return exactly: {EDUCATION}
|
- Do not invent education. If none is mentioned, return exactly: {EDUCATION}
|
||||||
- Do not invent job title. If none is mentioned, return exactly: {CURRENT_TITLE}
|
- Do not invent job title. If none is mentioned, return exactly: {CURRENT_TITLE}
|
||||||
|
|
||||||
|
skills (its own key — a JSON array of strings):
|
||||||
|
- List the candidate's concrete technical and professional skills: technologies, tools, languages, platforms, and named methodologies.
|
||||||
|
- Write each skill using the resume's own spelling. Every skill you return MUST appear in the resume text.
|
||||||
|
- Do not infer a skill from a job title, an employer, or a degree. "Backend Engineer" is not evidence of "Python".
|
||||||
|
- One skill per entry. Do not return sentences, responsibilities, or soft-skill filler like "team player" or "hard working".
|
||||||
|
- At most 30 entries, most relevant first. If the resume lists none, return an empty array [].
|
||||||
|
|
||||||
|
years_experience (its own key — an integer or null):
|
||||||
|
- If the resume states a total (for example "6 years of experience"), use that stated number.
|
||||||
|
- Otherwise compute whole years only from employment dates explicitly written in the resume.
|
||||||
|
- Never infer it from seniority words, education dates, or the number of jobs listed.
|
||||||
|
- Must be between 0 and 60. If the resume supports neither a stated total nor explicit dates, return null.
|
||||||
|
|
||||||
linkedin_url (its own key — extract this separately from the other fields):
|
linkedin_url (its own key — extract this separately from the other fields):
|
||||||
- Return the candidate's own public LinkedIn profile URL (linkedin.com/in/..., /pub/..., /mwlite/in/..., or lnkd.in/...).
|
- Return the candidate's own public LinkedIn profile URL (linkedin.com/in/..., /pub/..., /mwlite/in/..., or lnkd.in/...).
|
||||||
- Reconstruct the URL if PDF extraction wrapped or spaced it (e.g. "linkedin.com/in/\\njane-doe" or "linkedin . com / in / jane-doe").
|
- Reconstruct the URL if PDF extraction wrapped or spaced it (e.g. "linkedin.com/in/\\njane-doe" or "linkedin . com / in / jane-doe").
|
||||||
|
|
@ -51,14 +65,16 @@ phone (its own key — extract this separately; copy EVERY digit):
|
||||||
Examples of CORRECT values (copy this completeness; these are format samples, not this candidate):
|
Examples of CORRECT values (copy this completeness; these are format samples, not this candidate):
|
||||||
|
|
||||||
Example 1 — local 11-digit PK mobile, full LinkedIn:
|
Example 1 — local 11-digit PK mobile, full LinkedIn:
|
||||||
Resume: "Ali Khan | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer"
|
Resume: "Ali Khan | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer | Skills: Python, Django, PostgreSQL | 6 years of experience"
|
||||||
JSON:
|
JSON:
|
||||||
{{
|
{{
|
||||||
"current_employment": "Acme",
|
"current_employment": "Acme",
|
||||||
"education": "BS CS",
|
"education": "BS CS",
|
||||||
"current_title": "Engineer",
|
"current_title": "Engineer",
|
||||||
"linkedin_url": "https://www.linkedin.com/in/ali-khan",
|
"linkedin_url": "https://www.linkedin.com/in/ali-khan",
|
||||||
"phone": "0321-5551234"
|
"phone": "0321-5551234",
|
||||||
|
"skills": ["Python", "Django", "PostgreSQL"],
|
||||||
|
"years_experience": 6
|
||||||
}}
|
}}
|
||||||
|
|
||||||
Example 2 — +92 with spaces; every digit kept:
|
Example 2 — +92 with spaces; every digit kept:
|
||||||
|
|
@ -77,13 +93,23 @@ Example 5 — wrapped LinkedIn slug:
|
||||||
Resume: "linkedin.com/in/\\njane-doe-123"
|
Resume: "linkedin.com/in/\\njane-doe-123"
|
||||||
JSON linkedin_url must be "https://www.linkedin.com/in/jane-doe-123". Not ".../jane-doe".
|
JSON linkedin_url must be "https://www.linkedin.com/in/jane-doe-123". Not ".../jane-doe".
|
||||||
|
|
||||||
|
Example 6 — no stated total and no dates:
|
||||||
|
Resume: "Senior Architect. Led large teams."
|
||||||
|
JSON years_experience must be null. "Senior" is not a duration.
|
||||||
|
|
||||||
|
Example 7 — dates only:
|
||||||
|
Resume: "Acme, Jan 2018 - Jan 2024, Engineer"
|
||||||
|
JSON years_experience must be 6, and skills must be [] because none are listed.
|
||||||
|
|
||||||
Respond with JSON only:
|
Respond with JSON only:
|
||||||
{{
|
{{
|
||||||
"current_employment": "Company Name",
|
"current_employment": "Company Name",
|
||||||
"education": "Degree / School",
|
"education": "Degree / School",
|
||||||
"current_title": "Job Title",
|
"current_title": "Job Title",
|
||||||
"linkedin_url": "https://www.linkedin.com/in/slug",
|
"linkedin_url": "https://www.linkedin.com/in/slug",
|
||||||
"phone": "+92 300 1234567"
|
"phone": "+92 300 1234567",
|
||||||
|
"skills": ["Skill One", "Skill Two"],
|
||||||
|
"years_experience": 5
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,11 @@ async def fetch_form_data(
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
processing_state: str | None = Query(None),
|
processing_state: str | None = Query(None),
|
||||||
is_duplicate: bool | None = Query(None),
|
is_duplicate: bool | None = Query(None),
|
||||||
|
# Tri-valued, like is_duplicate above: omit for no filter, true for rows that
|
||||||
|
# have the link, false for the ones missing it. Chasing the gaps is half the
|
||||||
|
# reason these exist, so `false` has to be a real filter and not "unset".
|
||||||
|
has_linkedin: bool | None = Query(None),
|
||||||
|
has_resume: bool | None = Query(None),
|
||||||
offset: int = Query(0,ge=0),
|
offset: int = Query(0,ge=0),
|
||||||
# Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged.
|
# Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged.
|
||||||
limit: int | None = Query(None,ge=1,le=500),
|
limit: int | None = Query(None,ge=1,le=500),
|
||||||
|
|
@ -207,6 +212,7 @@ async def fetch_form_data(
|
||||||
items,total=await service.get_form_data(
|
items,total=await service.get_form_data(
|
||||||
sheet=sheet,search=search,offset=offset,limit=limit,
|
sheet=sheet,search=search,offset=offset,limit=limit,
|
||||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||||
|
has_linkedin=has_linkedin,has_resume=has_resume,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -218,12 +224,21 @@ async def fetch_form_data(
|
||||||
@router.get("/sheet/form-data/counts")
|
@router.get("/sheet/form-data/counts")
|
||||||
async def fetch_form_data_counts(
|
async def fetch_form_data_counts(
|
||||||
sheet: str | None = Query(None),
|
sheet: str | None = Query(None),
|
||||||
|
# The badges narrow with the list. Without these the tab counts describe the
|
||||||
|
# whole sheet while the rows beneath them describe a filtered slice.
|
||||||
|
# processing_state and is_duplicate are absent on purpose: those two ARE the
|
||||||
|
# tabs, so passing them would make every badge report the current tab.
|
||||||
|
search: str | None = Query(None),
|
||||||
|
has_linkedin: bool | None = Query(None),
|
||||||
|
has_resume: bool | None = Query(None),
|
||||||
current_user: dict = Depends(_FORM_DATA_READ),
|
current_user: dict = Depends(_FORM_DATA_READ),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=SheetFormData(session=session)
|
service=SheetFormData(session=session)
|
||||||
data=await service.get_counts(sheet=sheet)
|
data=await service.get_counts(
|
||||||
|
sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume,
|
||||||
|
)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, Index, case, delete, func, insert, or_
|
from sqlalchemy import Column, DateTime, Index, and_, case, delete, func, insert, or_
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, SQLModel, select
|
from sqlmodel import Field, SQLModel, select
|
||||||
|
|
@ -17,6 +17,17 @@ def _now() -> datetime:
|
||||||
|
|
||||||
_BULK_CHUNK = 1000
|
_BULK_CHUNK = 1000
|
||||||
|
|
||||||
|
# profile_link holds whatever the candidate typed into the form's "LinkedIn
|
||||||
|
# Profile Link" box. Nothing on the ingest path validates it — the real LinkedIn
|
||||||
|
# parsing runs only when a row is promoted, and writes to a different table — so
|
||||||
|
# matching on these is a heuristic, not proof of a profile. It misses a bare
|
||||||
|
# handle and it accepts a malformed URL that merely contains the domain.
|
||||||
|
#
|
||||||
|
# Module level, not a class attribute: SQLModel hands any leading-underscore
|
||||||
|
# class attribute to Pydantic, which turns it into a ModelPrivateAttr that is not
|
||||||
|
# iterable at class scope.
|
||||||
|
LINKEDIN_PATTERNS = ("%linkedin.com%", "%lnkd.in%")
|
||||||
|
|
||||||
|
|
||||||
class FormData(SQLModel, table=True):
|
class FormData(SQLModel, table=True):
|
||||||
"""One spreadsheet data row. raw_record keeps the full original header→value map."""
|
"""One spreadsheet data row. raw_record keeps the full original header→value map."""
|
||||||
|
|
@ -99,7 +110,10 @@ class FormData(SQLModel, table=True):
|
||||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _filters(cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None):
|
def _filters(
|
||||||
|
cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None,
|
||||||
|
has_linkedin=None, has_resume=None,
|
||||||
|
):
|
||||||
filters = []
|
filters = []
|
||||||
if sheet:
|
if sheet:
|
||||||
filters.append(cls.sheet == sheet)
|
filters.append(cls.sheet == sheet)
|
||||||
|
|
@ -107,6 +121,25 @@ class FormData(SQLModel, table=True):
|
||||||
filters.append(cls.processing_state == processing_state)
|
filters.append(cls.processing_state == processing_state)
|
||||||
if is_duplicate is not None:
|
if is_duplicate is not None:
|
||||||
filters.append(cls.is_duplicate == bool(is_duplicate))
|
filters.append(cls.is_duplicate == bool(is_duplicate))
|
||||||
|
if has_linkedin is not None:
|
||||||
|
matches = [cls.profile_link.ilike(p) for p in LINKEDIN_PATTERNS]
|
||||||
|
if has_linkedin:
|
||||||
|
filters.append(or_(*matches))
|
||||||
|
else:
|
||||||
|
# The NULL arm is load-bearing. `NOT (NULL ILIKE ...)` evaluates to
|
||||||
|
# NULL, which WHERE discards, so without it the rows with no link
|
||||||
|
# at all would drop out of the "no LinkedIn" view — precisely the
|
||||||
|
# rows that view exists to find.
|
||||||
|
filters.append(or_(
|
||||||
|
cls.profile_link.is_(None),
|
||||||
|
and_(*[~m for m in matches]),
|
||||||
|
))
|
||||||
|
if has_resume is not None:
|
||||||
|
# _cell() stores a blank sheet cell as NULL, never "", so a NULL test
|
||||||
|
# is the whole check and an empty-string arm would be dead weight.
|
||||||
|
filters.append(
|
||||||
|
cls.resume_link.is_not(None) if has_resume else cls.resume_link.is_(None)
|
||||||
|
)
|
||||||
if search:
|
if search:
|
||||||
# Twelve unanchored ILIKEs over ~26k rows is a sequential scan of a few
|
# Twelve unanchored ILIKEs over ~26k rows is a sequential scan of a few
|
||||||
# tens of ms — acceptable at this size; a pg_trgm GIN index is the
|
# tens of ms — acceptable at this size; a pg_trgm GIN index is the
|
||||||
|
|
@ -279,12 +312,14 @@ class FormData(SQLModel, table=True):
|
||||||
@classmethod
|
@classmethod
|
||||||
async def fetch_form_data(
|
async def fetch_form_data(
|
||||||
cls, session: AsyncSession, *, sheet=None, search=None,
|
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||||
processing_state=None, is_duplicate=None, offset=0, limit=None,
|
processing_state=None, is_duplicate=None, has_linkedin=None,
|
||||||
|
has_resume=None, offset=0, limit=None,
|
||||||
):
|
):
|
||||||
statement = select(cls).order_by(cls.sheet, cls.row_number)
|
statement = select(cls).order_by(cls.sheet, cls.row_number)
|
||||||
for clause in cls._filters(
|
for clause in cls._filters(
|
||||||
sheet=sheet, search=search,
|
sheet=sheet, search=search,
|
||||||
processing_state=processing_state, is_duplicate=is_duplicate,
|
processing_state=processing_state, is_duplicate=is_duplicate,
|
||||||
|
has_linkedin=has_linkedin, has_resume=has_resume,
|
||||||
):
|
):
|
||||||
statement = statement.where(clause)
|
statement = statement.where(clause)
|
||||||
if offset:
|
if offset:
|
||||||
|
|
@ -336,20 +371,36 @@ class FormData(SQLModel, table=True):
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_form_data(
|
async def count_form_data(
|
||||||
cls, session: AsyncSession, *, sheet=None, search=None,
|
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||||
processing_state=None, is_duplicate=None,
|
processing_state=None, is_duplicate=None, has_linkedin=None,
|
||||||
|
has_resume=None,
|
||||||
):
|
):
|
||||||
statement = select(func.count()).select_from(cls)
|
statement = select(func.count()).select_from(cls)
|
||||||
for clause in cls._filters(
|
for clause in cls._filters(
|
||||||
sheet=sheet, search=search,
|
sheet=sheet, search=search,
|
||||||
processing_state=processing_state, is_duplicate=is_duplicate,
|
processing_state=processing_state, is_duplicate=is_duplicate,
|
||||||
|
has_linkedin=has_linkedin, has_resume=has_resume,
|
||||||
):
|
):
|
||||||
statement = statement.where(clause)
|
statement = statement.where(clause)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_processing(cls, session: AsyncSession, *, sheet=None):
|
async def count_processing(
|
||||||
"""Tab badge counts for the Sheet Forms channel."""
|
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||||
|
has_linkedin=None, has_resume=None,
|
||||||
|
):
|
||||||
|
"""Tab badge counts for the Sheet Forms channel.
|
||||||
|
|
||||||
|
Narrowed by the same predicates as the list, through the same _filters()
|
||||||
|
call, because a badge that disagrees with the rows under it reads as a
|
||||||
|
bug. This used to take only `sheet`, so switching on the search box
|
||||||
|
already left "All Applications 612" sitting above twelve rows; adding
|
||||||
|
the link filters would have made that worse.
|
||||||
|
|
||||||
|
processing_state and is_duplicate are deliberately NOT accepted: those
|
||||||
|
two ARE the tabs. Passing them would have each badge count only its own
|
||||||
|
tab, so every badge would report the tab the user is already on.
|
||||||
|
"""
|
||||||
statement = select(
|
statement = select(
|
||||||
func.count().label("all"),
|
func.count().label("all"),
|
||||||
func.coalesce(func.sum(case((cls.processing_state == "unread", 1), else_=0)), 0).label("unread"),
|
func.coalesce(func.sum(case((cls.processing_state == "unread", 1), else_=0)), 0).label("unread"),
|
||||||
|
|
@ -358,8 +409,11 @@ class FormData(SQLModel, table=True):
|
||||||
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
|
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
|
||||||
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
|
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
|
||||||
).select_from(cls)
|
).select_from(cls)
|
||||||
if sheet:
|
for clause in cls._filters(
|
||||||
statement = statement.where(cls.sheet == sheet)
|
sheet=sheet, search=search,
|
||||||
|
has_linkedin=has_linkedin, has_resume=has_resume,
|
||||||
|
):
|
||||||
|
statement = statement.where(clause)
|
||||||
row = (await session.execute(statement)).one()
|
row = (await session.execute(statement)).one()
|
||||||
return {
|
return {
|
||||||
"all": int(row.all or 0),
|
"all": int(row.all or 0),
|
||||||
|
|
|
||||||
|
|
@ -431,16 +431,18 @@ class SheetFormData(Sheet):
|
||||||
|
|
||||||
async def get_form_data(
|
async def get_form_data(
|
||||||
self,sheet=None,search=None,offset=0,limit=None,
|
self,sheet=None,search=None,offset=0,limit=None,
|
||||||
processing_state=None,is_duplicate=None,
|
processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None,
|
||||||
):
|
):
|
||||||
session=self._require_session()
|
session=self._require_session()
|
||||||
rows=await FormData.fetch_form_data(
|
rows=await FormData.fetch_form_data(
|
||||||
session,sheet=sheet,search=search,offset=offset,limit=limit,
|
session,sheet=sheet,search=search,offset=offset,limit=limit,
|
||||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||||
|
has_linkedin=has_linkedin,has_resume=has_resume,
|
||||||
)
|
)
|
||||||
total=await FormData.count_form_data(
|
total=await FormData.count_form_data(
|
||||||
session,sheet=sheet,search=search,
|
session,sheet=sheet,search=search,
|
||||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||||
|
has_linkedin=has_linkedin,has_resume=has_resume,
|
||||||
)
|
)
|
||||||
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
|
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
|
||||||
from job.candidate.views import CandidateView
|
from job.candidate.views import CandidateView
|
||||||
|
|
@ -587,11 +589,17 @@ class SheetFormData(Sheet):
|
||||||
raise HTTPException(status_code=404,detail="Form data not found")
|
raise HTTPException(status_code=404,detail="Form data not found")
|
||||||
return await self.get_form_data_by_id(record_id)
|
return await self.get_form_data_by_id(record_id)
|
||||||
|
|
||||||
async def get_counts(self,sheet=None):
|
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None):
|
||||||
return await FormData.count_processing(self._require_session(),sheet=sheet)
|
return await FormData.count_processing(
|
||||||
|
self._require_session(),sheet=sheet,search=search,
|
||||||
|
has_linkedin=has_linkedin,has_resume=has_resume,
|
||||||
|
)
|
||||||
|
|
||||||
async def count_rows(self,sheet=None):
|
async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None):
|
||||||
return await FormData.count_form_data(self._require_session(),sheet=sheet)
|
return await FormData.count_form_data(
|
||||||
|
self._require_session(),sheet=sheet,search=search,
|
||||||
|
has_linkedin=has_linkedin,has_resume=has_resume,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_imported_sheets(self):
|
async def get_imported_sheets(self):
|
||||||
session=self._require_session()
|
session=self._require_session()
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,86 @@ class Inbox(SQLModel, table=True):
|
||||||
})
|
})
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_silver_medalists(cls, session: AsyncSession, *, min_score=60, limit=500):
|
||||||
|
"""Rejected applicants who scored well — the CV Bank's second population.
|
||||||
|
|
||||||
|
Read live rather than copied into manual_upload_candidate: these rows
|
||||||
|
already exist, and a copy would immediately start drifting from the
|
||||||
|
application it was taken from.
|
||||||
|
|
||||||
|
Only REJECTED counts. CLOSED is the ingest DEFAULT for any unprocessed
|
||||||
|
email (see Inbox_Messages.application_status), so treating it as a
|
||||||
|
rejection would tip the entire unread inbox into the bank.
|
||||||
|
|
||||||
|
Requiring a score is what makes these "silver" rather than merely
|
||||||
|
"not hired": an unscored rejection carries no evidence worth keeping.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
from job.candidate.models import Candidates
|
||||||
|
qry=(
|
||||||
|
select(
|
||||||
|
cls.id.label("inbox_id"),
|
||||||
|
cls.user_id,
|
||||||
|
Users.name,
|
||||||
|
Users.email,
|
||||||
|
Users.linkedin_url,
|
||||||
|
Inbox_Messages.candidate_phone_number.label("phone"),
|
||||||
|
Inbox_Messages.current_employment.label("current_company"),
|
||||||
|
Inbox_Messages.current_title,
|
||||||
|
Inbox_Messages.candidate_education.label("education"),
|
||||||
|
Inbox_Messages.file_name,
|
||||||
|
Inbox_Messages.file_path,
|
||||||
|
Inbox_Messages.ats_score,
|
||||||
|
Inbox_Messages.ats_band,
|
||||||
|
cls.created_at,
|
||||||
|
JobPosts.title.label("last_job_title"),
|
||||||
|
Candidates.matched_keywords,
|
||||||
|
Candidates.years_experience,
|
||||||
|
)
|
||||||
|
.join(Users,cls.user_id==Users.id)
|
||||||
|
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
|
||||||
|
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||||
|
.outerjoin(AtsResults,cls.ats_id==AtsResults.id)
|
||||||
|
# candidate_id is NULL whenever the CV email matched a user, so
|
||||||
|
# this join only sometimes lands — hence the keyword list being
|
||||||
|
# optional rather than the filter.
|
||||||
|
.outerjoin(Candidates,AtsResults.candidate_id==Candidates.id)
|
||||||
|
.where(Inbox_Messages.application_status==Candidate_application_Status.REJECTED)
|
||||||
|
.where(Inbox_Messages.ats_score.is_not(None))
|
||||||
|
.where(Inbox_Messages.ats_score>=float(min_score))
|
||||||
|
.where(Inbox_Messages.is_duplicate==False) # noqa: E712
|
||||||
|
.order_by(Inbox_Messages.ats_score.desc(),cls.created_at.desc(),cls.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result=await session.execute(qry)
|
||||||
|
rows=[]
|
||||||
|
for row in result.mappings().all():
|
||||||
|
rows.append({
|
||||||
|
"inbox_id":row["inbox_id"],
|
||||||
|
"user_id":str(row["user_id"]) if row["user_id"] else None,
|
||||||
|
"name":row["name"],
|
||||||
|
"email":row["email"],
|
||||||
|
"phone":row["phone"] or None,
|
||||||
|
"linkedin_url":row["linkedin_url"] or None,
|
||||||
|
"current_company":row["current_company"] or None,
|
||||||
|
"current_title":row["current_title"] or None,
|
||||||
|
"education":row["education"] or None,
|
||||||
|
"file_name":row["file_name"] or None,
|
||||||
|
"file_path":row["file_path"] or None,
|
||||||
|
"ai_score":int(row["ats_score"]) if row["ats_score"] is not None else None,
|
||||||
|
"recommendation":row["ats_band"] or None,
|
||||||
|
"last_job_title":row["last_job_title"] or None,
|
||||||
|
"matched_keywords":list(row["matched_keywords"] or []),
|
||||||
|
"years_experience":row["years_experience"],
|
||||||
|
"bank_expires_at":None,
|
||||||
|
"created_at":row["created_at"],
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def linkedin_urls_by_message_ids(cls, session: AsyncSession, message_ids) -> dict:
|
async def linkedin_urls_by_message_ids(cls, session: AsyncSession, message_ids) -> dict:
|
||||||
"""users.linkedin_url keyed by inbox_messages.id for one list page."""
|
"""users.linkedin_url keyed by inbox_messages.id for one list page."""
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query,Response
|
||||||
from fastapi.responses import FileResponse,JSONResponse
|
from fastapi.responses import FileResponse,JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
from job.candidate.views import CandidateScoring,FileRead,CandidateView,parse_linkedin_url_from_cv
|
from job.candidate.views import CandidateScoring,FileRead,CandidateView,extract_bank_profile_from_cv,parse_linkedin_url_from_cv
|
||||||
from job.interviews.views import Interview
|
from job.interviews.views import Interview
|
||||||
from job.notes.views import Note
|
from job.notes.views import Note
|
||||||
from job.activity.views import ActivityLog
|
from job.activity.views import ActivityLog
|
||||||
|
|
@ -25,6 +25,7 @@ from datetime import datetime, time, timezone
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
@ -32,12 +33,25 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# A banked CV is personal data held with no job to justify it, so it is held for
|
||||||
|
# a stated period rather than forever. Stamped on the row at upload so changing
|
||||||
|
# the setting later cannot silently extend CVs already taken in.
|
||||||
|
CV_BANK_RETENTION_MONTHS = int(os.getenv("CV_BANK_RETENTION_MONTHS", "24"))
|
||||||
|
# Deterministic keyword overlap, not comprehension — the floor only decides who
|
||||||
|
# is worth telling a recruiter about, never who is qualified.
|
||||||
|
CV_BANK_SUGGEST_THRESHOLD = int(os.getenv("CV_BANK_SUGGEST_THRESHOLD", "55"))
|
||||||
|
|
||||||
|
|
||||||
class MatchingAssign(BaseModel):
|
class MatchingAssign(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
job_post_id: UUID | None = None
|
job_post_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CvBankScoreRequest(BaseModel):
|
||||||
|
job_id: UUID
|
||||||
|
ids: list[UUID]
|
||||||
|
|
||||||
|
|
||||||
class CandidateUpdate(BaseModel):
|
class CandidateUpdate(BaseModel):
|
||||||
favorite: bool | None = None
|
favorite: bool | None = None
|
||||||
rating: float | None = None
|
rating: float | None = None
|
||||||
|
|
@ -321,7 +335,10 @@ async def cv_bank_upload(
|
||||||
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
|
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
|
||||||
text=parsed.get("text") or ""
|
text=parsed.get("text") or ""
|
||||||
detected,_=extract_candidate_email(text)
|
detected,_=extract_candidate_email(text)
|
||||||
parsed_linkedin=await parse_linkedin_url_from_cv(text)
|
# One agent call for the whole profile. Banking is the only ingest path
|
||||||
|
# with no job attached, so this is the CV's only structured data until
|
||||||
|
# a recruiter scores it against a real opening.
|
||||||
|
profile=await extract_bank_profile_from_cv(text)
|
||||||
# Basename against both separator styles — a Windows client sends
|
# Basename against both separator styles — a Windows client sends
|
||||||
# C:\Users\x\cv.pdf whose PosixPath name is the whole string.
|
# C:\Users\x\cv.pdf whose PosixPath name is the whole string.
|
||||||
original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf"
|
original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf"
|
||||||
|
|
@ -333,7 +350,10 @@ async def cv_bank_upload(
|
||||||
file_name=original,
|
file_name=original,
|
||||||
created_by=current_user.get("id"),
|
created_by=current_user.get("id"),
|
||||||
pdf_bytes=content,
|
pdf_bytes=content,
|
||||||
linkedin_url=parsed_linkedin,
|
linkedin_url=profile.get("linkedin_url"),
|
||||||
|
profile=profile,
|
||||||
|
bank_reason="speculative",
|
||||||
|
retention_months=CV_BANK_RETENTION_MONTHS,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
uploaded=S3().upload_for_record(
|
uploaded=S3().upload_for_record(
|
||||||
|
|
@ -372,27 +392,84 @@ async def cv_bank_upload(
|
||||||
async def cv_bank_fetch(
|
async def cv_bank_fetch(
|
||||||
top: int = Query(100, ge=1, le=500),
|
top: int = Query(100, ge=1, le=500),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
|
source: Literal["speculative","silver_medalist"] | None = Query(default=None),
|
||||||
|
search: str | None = Query(default=None),
|
||||||
|
skills: list[str] | None = Query(default=None),
|
||||||
|
min_years: int | None = Query(default=None, ge=0, le=60),
|
||||||
|
band: str | None = Query(default=None),
|
||||||
|
job_post_id: str | None = Query(default=None),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""The stored-CV bank, newest first. Download the file via
|
"""The CV Bank: speculative uploads plus rejected applicants who scored well.
|
||||||
GET /documents/download?manual_upload_candidate_id=<id>."""
|
|
||||||
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
`job_post_id` does not filter the list — it attaches the deterministic
|
||||||
|
tier-1 rank for that job and sorts by it, which is the "a role just opened,
|
||||||
|
who do we already have" view. Download a file via
|
||||||
|
GET /candidate/cv-bank/file?id=<record_id>."""
|
||||||
try:
|
try:
|
||||||
rows,total=await Manual_UPLOAD_CANDIDATE.list_bank(session,limit=top,offset=skip)
|
service=CandidateView(session=session)
|
||||||
data=[{
|
data,total=await service.list_bank(
|
||||||
"id":str(r.id),
|
source=source,search=search,skills=skills,min_years=min_years,
|
||||||
"file_name":r.file_name,
|
band=band,job_post_id=job_post_id,limit=top,offset=skip,
|
||||||
"file_path":(r.file_path or "").strip() or None,
|
)
|
||||||
"candidate_email":r.candidate_email or None,
|
|
||||||
"candidate_name":r.candidate_name or None,
|
|
||||||
"linkedin_url":r.linkedin_url or None,
|
|
||||||
"created_at":r.created_at.isoformat() if r.created_at else None,
|
|
||||||
} for r in rows]
|
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.exception("cv-bank fetch failed")
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidate/cv-bank/score")
|
||||||
|
async def cv_bank_score(
|
||||||
|
payload: CvBankScoreRequest,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Run the real ATS score on CVs already in the bank — the paid tier.
|
||||||
|
|
||||||
|
No upload: the bytes are already stored. Mirrors POST /candidate/score_inbox,
|
||||||
|
and the results land in candidates / ats_results like any other scored CV,
|
||||||
|
so a banked candidate shows up on the leaderboard the same way."""
|
||||||
|
try:
|
||||||
|
service=CandidateScoring(session=session)
|
||||||
|
data=await service.score_bank(str(payload.job_id),payload.ids,current_user)
|
||||||
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("cv-bank scoring failed")
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidate/cv-bank/suggestions")
|
||||||
|
async def cv_bank_suggestions(
|
||||||
|
job_post_id: str = Query(...),
|
||||||
|
top: int = Query(20, ge=1, le=200),
|
||||||
|
min_rank: int | None = Query(default=None, ge=0, le=100),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Banked CVs worth looking at for one job, best first.
|
||||||
|
|
||||||
|
rank_score is deterministic keyword overlap, not an ATS score — it orders
|
||||||
|
the bank so a recruiter knows where to start. Scoring for real costs money
|
||||||
|
and happens via POST /candidate/cv-bank/score on the ones they pick."""
|
||||||
|
try:
|
||||||
|
service=CandidateView(session=session)
|
||||||
|
floor=CV_BANK_SUGGEST_THRESHOLD if min_rank is None else min_rank
|
||||||
|
rows,_=await service.list_bank(
|
||||||
|
job_post_id=job_post_id,limit=service.BANK_SCAN_CAP,offset=0,
|
||||||
|
)
|
||||||
|
data=[r for r in rows if (r.get("rank_score") or 0)>=floor][:top]
|
||||||
|
return JSONResponse(content={
|
||||||
|
"data":data,"total":len(data),"threshold":floor,"status_code":200,
|
||||||
|
})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("cv-bank suggestions failed")
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""CV Bank Taskiq tasks — profile backfill and job-opening rank.
|
||||||
|
|
||||||
|
Worker: taskiq worker taskiq_management.broker_setup:broker job.candidate.bank_tasks
|
||||||
|
|
||||||
|
Two jobs live here, both about the bank being useful rather than merely stored:
|
||||||
|
|
||||||
|
cvbank.backfill_profiles one-off, for CVs banked before extraction existed
|
||||||
|
cvbank.rank_for_job fired when a job opens, so the bank is offered up
|
||||||
|
instead of waiting to be remembered
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from db_setup import session_scope
|
||||||
|
from taskiq_management.broker_setup import MAX_RETRIES, RETRY_DELAY, broker
|
||||||
|
from taskiq_management.middleware import PermanentTaskError
|
||||||
|
|
||||||
|
logger = logging.getLogger("cvbank.tasks")
|
||||||
|
|
||||||
|
# One agent call per CV, so a backfill of a large bank is paced across runs
|
||||||
|
# rather than fired as one unbounded burst.
|
||||||
|
BACKFILL_BATCH = 25
|
||||||
|
|
||||||
|
# 03:00 daily. The sweep only flags, so the exact hour does not matter; off-peak
|
||||||
|
# just keeps it away from the scoring workload.
|
||||||
|
RETENTION_SWEEP_CRON = os.getenv("CV_BANK_RETENTION_CRON", "0 3 * * *")
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(
|
||||||
|
task_name="cvbank.backfill_profiles",
|
||||||
|
retry_on_error=True,
|
||||||
|
max_retries=MAX_RETRIES,
|
||||||
|
delay=RETRY_DELAY,
|
||||||
|
)
|
||||||
|
async def backfill_bank_profiles(limit: int = BACKFILL_BATCH) -> dict:
|
||||||
|
"""Extract skills/title/company/years for CVs banked before migration 029.
|
||||||
|
|
||||||
|
Re-runnable: rows are selected by "has no extraction yet", so a finished
|
||||||
|
bank returns scanned=0 and the task becomes a no-op. Returns `remaining`
|
||||||
|
so a caller can decide whether to enqueue another batch.
|
||||||
|
"""
|
||||||
|
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||||
|
from job.candidate.views import extract_bank_profile_from_cv
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
failed = 0
|
||||||
|
async with session_scope() as session:
|
||||||
|
rows = await Manual_UPLOAD_CANDIDATE.list_bank_needing_profile(
|
||||||
|
session, limit=max(1, int(limit or BACKFILL_BATCH)),
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
# extract_bank_profile_from_cv never raises, but a bad row must not
|
||||||
|
# cost the whole batch either.
|
||||||
|
try:
|
||||||
|
profile = await extract_bank_profile_from_cv(row.full_text)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("bank profile backfill failed id=%s", row.id)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
if not any(profile.get(k) for k in ("skills", "current_position", "current_company")):
|
||||||
|
continue
|
||||||
|
await Manual_UPLOAD_CANDIDATE.set_bank_profile(session, row.id, profile)
|
||||||
|
updated += 1
|
||||||
|
remaining = len(
|
||||||
|
await Manual_UPLOAD_CANDIDATE.list_bank_needing_profile(session, limit=1)
|
||||||
|
)
|
||||||
|
return {"scanned": len(rows), "updated": updated, "failed": failed, "remaining": remaining}
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(
|
||||||
|
task_name="cvbank.rank_for_job",
|
||||||
|
retry_on_error=True,
|
||||||
|
max_retries=MAX_RETRIES,
|
||||||
|
delay=RETRY_DELAY,
|
||||||
|
)
|
||||||
|
async def rank_bank_for_job(job_post_id: str) -> dict:
|
||||||
|
"""Score every banked CV against a newly opened job — tier 1, free.
|
||||||
|
|
||||||
|
Deterministic keyword overlap only. No LLM call, so this runs over the
|
||||||
|
whole bank on every job opening without a bill; the paid ATS score happens
|
||||||
|
later and only for the handful a recruiter shortlists.
|
||||||
|
"""
|
||||||
|
from job.candidate.models import CvBankMatches, Manual_UPLOAD_CANDIDATE
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
from matching.ranking import rank_bank_row
|
||||||
|
|
||||||
|
if not job_post_id or not str(job_post_id).strip():
|
||||||
|
raise PermanentTaskError("job_post_id is required")
|
||||||
|
job_post_id = str(job_post_id).strip()
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
job = await JobPosts.get_job_post_by_id(session, job_post_id)
|
||||||
|
if job is None or job.is_deleted:
|
||||||
|
raise PermanentTaskError("job post missing or deleted")
|
||||||
|
job_fields = {
|
||||||
|
"title": job.title,
|
||||||
|
"requirements": job.requirements,
|
||||||
|
"optional_skills": job.optional_skills,
|
||||||
|
}
|
||||||
|
rows = await Manual_UPLOAD_CANDIDATE.list_bank_for_ranking(session)
|
||||||
|
scores = [(row.id, rank_bank_row(job_fields, row)) for row in rows]
|
||||||
|
await CvBankMatches.replace_for_job(session, job.id, scores)
|
||||||
|
|
||||||
|
threshold = _suggest_threshold()
|
||||||
|
strong = [s for _, s in scores if s >= threshold]
|
||||||
|
if strong:
|
||||||
|
await _notify_owner(job_post_id, len(strong))
|
||||||
|
return {"ranked": len(scores), "above_threshold": len(strong)}
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(
|
||||||
|
task_name="cvbank.sweep_expired",
|
||||||
|
schedule=[{"cron": RETENTION_SWEEP_CRON}],
|
||||||
|
)
|
||||||
|
async def sweep_expired_bank_cvs() -> dict:
|
||||||
|
"""Flag banked CVs past their retention window — nightly.
|
||||||
|
|
||||||
|
Flags, never deletes. These are resumes a person sent us: dropping them on
|
||||||
|
a timer with no record would be worse than holding them, and a wrongly
|
||||||
|
configured window would silently destroy the whole bank. A human decides,
|
||||||
|
the sweep only makes the decision unavoidable.
|
||||||
|
|
||||||
|
Expired rows are already excluded from ranking (list_bank_for_ranking), so
|
||||||
|
nothing is being surfaced to recruiters in the meantime.
|
||||||
|
"""
|
||||||
|
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
rows = await Manual_UPLOAD_CANDIDATE.list_bank_expired(session)
|
||||||
|
for row in rows:
|
||||||
|
logger.info(
|
||||||
|
"cv-bank retention expired id=%s banked_at=%s expired_at=%s",
|
||||||
|
row.id,
|
||||||
|
row.created_at.isoformat() if row.created_at else None,
|
||||||
|
row.bank_expires_at.isoformat() if row.bank_expires_at else None,
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
await _notify_retention_review(len(rows))
|
||||||
|
return {"expired": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_retention_review(count: int) -> None:
|
||||||
|
"""Tell whoever banked the CVs that the window has run out.
|
||||||
|
|
||||||
|
Best effort — the log line above is the durable record.
|
||||||
|
"""
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
try:
|
||||||
|
from notifications.models import Notifications
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
recipient = os.getenv("CV_BANK_RETENTION_NOTIFY_EMAIL", "").strip().lower()
|
||||||
|
if not recipient:
|
||||||
|
return
|
||||||
|
async with session_scope() as session:
|
||||||
|
user = await Users.get_user_by_email(session, recipient)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
await Notifications.insert_notification(session, {
|
||||||
|
"user_id": _uuid.UUID(str(user.id)),
|
||||||
|
"kind": "system",
|
||||||
|
"title": "CV Bank retention review",
|
||||||
|
"body": (
|
||||||
|
f"{count} stored CV{'s' if count != 1 else ''} passed the retention "
|
||||||
|
"window and need to be kept with a reason or deleted."
|
||||||
|
),
|
||||||
|
"link_path": "/cvbank",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("cv-bank retention notification failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _suggest_threshold() -> int:
|
||||||
|
return int(os.getenv("CV_BANK_SUGGEST_THRESHOLD", "55"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_owner(job_post_id: str, count: int) -> None:
|
||||||
|
"""Tell the job's recruiter the bank already holds plausible candidates.
|
||||||
|
|
||||||
|
This is the whole point of ranking on job creation: without it the bank
|
||||||
|
only gets searched by someone who remembers it exists.
|
||||||
|
|
||||||
|
Best effort — a missing notification must never fail the ranking that has
|
||||||
|
already been persisted.
|
||||||
|
"""
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
try:
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
from notifications.models import Notifications
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
job = await JobPosts.get_job_post_by_id(session, job_post_id)
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
raw = getattr(job, "current_recruiter_id", None) or getattr(job, "created_by", None)
|
||||||
|
if not raw:
|
||||||
|
return
|
||||||
|
await Notifications.insert_notification(session, {
|
||||||
|
"user_id": _uuid.UUID(str(raw)),
|
||||||
|
"kind": "application",
|
||||||
|
"title": "CVs in the bank match this job",
|
||||||
|
"body": (
|
||||||
|
f"{count} stored CV{'s' if count != 1 else ''} look relevant to "
|
||||||
|
f"{job.title}. Open the CV Bank to review them."
|
||||||
|
),
|
||||||
|
"link_path": f"/cvbank?job={job_post_id}",
|
||||||
|
"job_post_id": job.id,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("cv-bank suggestion notification failed job=%s", job_post_id)
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_
|
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
@ -52,6 +53,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
platform: str = Field(default="")
|
platform: str = Field(default="")
|
||||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||||
experience: str = Field(default="")
|
experience: str = Field(default="")
|
||||||
|
# Employment-agent extractions, written at CV-bank ingest (see 029). These
|
||||||
|
# are what make the bank searchable — full_text alone cannot be filtered on.
|
||||||
|
# `experience` above is free text from the Add Candidate form; this one is
|
||||||
|
# the numeric years the bank filters and sorts by, so they stay separate.
|
||||||
|
skills: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"})
|
||||||
|
years_experience: int | None = Field(default=None)
|
||||||
|
education: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
|
# Why the CV is held (speculative / referral) and when retention expires.
|
||||||
|
bank_reason: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
|
bank_expires_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Shortlist.
|
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Shortlist.
|
||||||
status: str = Field(default="")
|
status: str = Field(default="")
|
||||||
# Free text, not a users FK: a referrer is often someone outside the system
|
# Free text, not a users FK: a referrer is often someone outside the system
|
||||||
|
|
@ -512,7 +523,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
candidate_name, full_text, file_name,
|
candidate_name, full_text, file_name,
|
||||||
created_by, pdf_bytes,
|
created_by, pdf_bytes,
|
||||||
content_type="application/pdf",
|
content_type="application/pdf",
|
||||||
linkedin_url=None):
|
linkedin_url=None, profile=None,
|
||||||
|
bank_reason="speculative", retention_months=None):
|
||||||
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit.
|
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit.
|
||||||
file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload).
|
file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload).
|
||||||
|
|
||||||
|
|
@ -521,7 +533,13 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
candidate ACCOUNT is created/reused so the person shows up on the
|
candidate ACCOUNT is created/reused so the person shows up on the
|
||||||
Candidates screen; unlike an application there is still no inbox entry,
|
Candidates screen; unlike an application there is still no inbox entry,
|
||||||
no scoring, and no setup email. A CV with no detectable email banks
|
no scoring, and no setup email. A CV with no detectable email banks
|
||||||
fine and simply stays account-less."""
|
fine and simply stays account-less.
|
||||||
|
|
||||||
|
`profile` is the rest of the employment-agent extraction (company,
|
||||||
|
title, education, phone, skills, years) — the only structured data a
|
||||||
|
banked CV gets, since nothing scores it until it is matched to a job.
|
||||||
|
`retention_months` stamps bank_expires_at so the CV is held for a
|
||||||
|
stated period rather than indefinitely."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from role.models import EnumRoles, Roles
|
from role.models import EnumRoles, Roles
|
||||||
|
|
@ -550,7 +568,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
user.is_active = True
|
user.is_active = True
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|
||||||
url = (linkedin_url or "").strip() or None
|
extracted = profile or {}
|
||||||
|
url = (linkedin_url or extracted.get("linkedin_url") or "").strip() or None
|
||||||
if url:
|
if url:
|
||||||
linkedin_slug = slug_from_url(url) or NO_SLUG
|
linkedin_slug = slug_from_url(url) or NO_SLUG
|
||||||
else:
|
else:
|
||||||
|
|
@ -558,13 +577,25 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
if user and url:
|
if user and url:
|
||||||
await Users.set_linkedin_url_if_empty(session, user_id=user.id, url=url)
|
await Users.set_linkedin_url_if_empty(session, user_id=user.id, url=url)
|
||||||
|
|
||||||
|
expires_at = None
|
||||||
|
if retention_months:
|
||||||
|
expires_at = _now() + timedelta(days=30 * int(retention_months))
|
||||||
|
|
||||||
row = cls(
|
row = cls(
|
||||||
candidate_email=email,
|
candidate_email=email,
|
||||||
candidate_name=(candidate_name or "").strip() or (email or ""),
|
candidate_name=(candidate_name or "").strip() or (email or ""),
|
||||||
|
candidate_phone=(extracted.get("candidate_phone") or "").strip(),
|
||||||
job_post_id=None,
|
job_post_id=None,
|
||||||
full_text=full_text or "",
|
full_text=full_text or "",
|
||||||
linkedin_slug=linkedin_slug,
|
linkedin_slug=linkedin_slug,
|
||||||
linkedin_url=url,
|
linkedin_url=url,
|
||||||
|
current_company=(extracted.get("current_company") or "").strip(),
|
||||||
|
current_position=(extracted.get("current_position") or "").strip(),
|
||||||
|
education=(extracted.get("education") or "").strip(),
|
||||||
|
skills=extracted.get("skills") or [],
|
||||||
|
years_experience=extracted.get("years_experience"),
|
||||||
|
bank_reason=(bank_reason or "").strip(),
|
||||||
|
bank_expires_at=expires_at,
|
||||||
apply_via="cv_bank",
|
apply_via="cv_bank",
|
||||||
user_id=user.id if user else None,
|
user_id=user.id if user else None,
|
||||||
created_by=cls._as_uuid(created_by),
|
created_by=cls._as_uuid(created_by),
|
||||||
|
|
@ -603,6 +634,58 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return list(result.scalars().all()), total
|
return list(result.scalars().all()), total
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_bank_needing_profile(cls, session: AsyncSession, limit=50):
|
||||||
|
"""Bank rows stored before the extraction existed (see 029).
|
||||||
|
|
||||||
|
Skills is the marker: a CV that genuinely lists none still gets its
|
||||||
|
title or years filled, so an empty skills array plus a blank title
|
||||||
|
means the agent never ran, not that the CV was sparse.
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
cls.apply_via == "cv_bank",
|
||||||
|
cls.full_text != "",
|
||||||
|
func.coalesce(func.jsonb_array_length(cls.skills), 0) == 0,
|
||||||
|
or_(cls.current_position == "", cls.current_position.is_(None)),
|
||||||
|
)
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_bank_profile(cls, session: AsyncSession, record_id, profile):
|
||||||
|
"""Write an employment-agent extraction onto an existing bank row.
|
||||||
|
|
||||||
|
Only fills blanks — a recruiter may have corrected the company or title
|
||||||
|
by hand, and a backfill must not overwrite that.
|
||||||
|
"""
|
||||||
|
row = await session.get(cls, cls._as_uuid(record_id))
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if not (row.current_company or "").strip():
|
||||||
|
row.current_company = (profile.get("current_company") or "").strip()
|
||||||
|
if not (row.current_position or "").strip():
|
||||||
|
row.current_position = (profile.get("current_position") or "").strip()
|
||||||
|
if not (row.education or "").strip():
|
||||||
|
row.education = (profile.get("education") or "").strip()
|
||||||
|
if not (row.candidate_phone or "").strip():
|
||||||
|
row.candidate_phone = (profile.get("candidate_phone") or "").strip()
|
||||||
|
if not row.skills:
|
||||||
|
row.skills = profile.get("skills") or []
|
||||||
|
if row.years_experience is None:
|
||||||
|
row.years_experience = profile.get("years_experience")
|
||||||
|
if not (row.linkedin_url or "").strip() and profile.get("linkedin_url"):
|
||||||
|
row.linkedin_url = profile["linkedin_url"]
|
||||||
|
row.linkedin_slug = slug_from_url(profile["linkedin_url"]) or NO_SLUG
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_matching(cls, session: AsyncSession, *, assigned=None,
|
async def list_matching(cls, session: AsyncSession, *, assigned=None,
|
||||||
search=None, limit=100, offset=0):
|
search=None, limit=100, offset=0):
|
||||||
|
|
@ -695,6 +778,41 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_bank_for_ranking(cls, session: AsyncSession, limit=5000):
|
||||||
|
"""Every CV still in the bank, for tier-1 ranking against a new job.
|
||||||
|
|
||||||
|
Expired CVs are excluded: ranking one would put a candidate in front of
|
||||||
|
a recruiter after the retention window said to stop holding them.
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
cls.apply_via == "cv_bank",
|
||||||
|
cls.job_post_id.is_(None),
|
||||||
|
or_(cls.bank_expires_at.is_(None), cls.bank_expires_at > _now()),
|
||||||
|
)
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_bank_expired(cls, session: AsyncSession, limit=500):
|
||||||
|
"""Bank CVs past their retention window, for the nightly sweep."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
cls.apply_via == "cv_bank",
|
||||||
|
cls.job_post_id.is_(None),
|
||||||
|
cls.bank_expires_at.is_not(None),
|
||||||
|
cls.bank_expires_at <= _now(),
|
||||||
|
)
|
||||||
|
.order_by(cls.bank_expires_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete_bank_cv(cls, session: AsyncSession, record_id):
|
async def delete_bank_cv(cls, session: AsyncSession, record_id):
|
||||||
"""Hard delete unassigned bank rows only — assigned rows are applications.
|
"""Hard delete unassigned bank rows only — assigned rows are applications.
|
||||||
|
|
@ -710,6 +828,60 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
class CvBankMatches(SQLModel, table=True):
|
||||||
|
"""Tier-1 rank of one banked CV against one job post (see 030).
|
||||||
|
|
||||||
|
Persisted rather than computed on read because the whole point is to tell a
|
||||||
|
recruiter the bank already holds candidates the moment a job opens — a
|
||||||
|
notification cannot wait for someone to open the screen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "cv_bank_matches"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"manual_upload_candidate_id", "job_post_id",
|
||||||
|
name="uq_cv_bank_matches_pair",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
manual_upload_candidate_id: uuid.UUID = Field(foreign_key="manual_upload_candidate.id")
|
||||||
|
job_post_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True)
|
||||||
|
rank_score: int = Field(default=0)
|
||||||
|
computed_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def replace_for_job(cls, session: AsyncSession, job_post_id, scores):
|
||||||
|
"""Swap in a fresh ranking for one job.
|
||||||
|
|
||||||
|
Delete-then-insert rather than upsert: a re-rank after the job's
|
||||||
|
requirements were edited must not leave behind scores for CVs that have
|
||||||
|
since been assigned or deleted.
|
||||||
|
"""
|
||||||
|
jid = job_post_id if isinstance(job_post_id, uuid.UUID) else uuid.UUID(str(job_post_id))
|
||||||
|
existing = await session.execute(select(cls).where(cls.job_post_id == jid))
|
||||||
|
for row in existing.scalars().all():
|
||||||
|
await session.delete(row)
|
||||||
|
await session.flush()
|
||||||
|
for record_id, score in scores:
|
||||||
|
session.add(cls(
|
||||||
|
manual_upload_candidate_id=record_id,
|
||||||
|
job_post_id=jid,
|
||||||
|
rank_score=int(score or 0),
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
return len(scores)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def scores_for_job(cls, session: AsyncSession, job_post_id) -> dict:
|
||||||
|
"""rank_score keyed by manual_upload_candidate_id, as strings."""
|
||||||
|
jid = job_post_id if isinstance(job_post_id, uuid.UUID) else uuid.UUID(str(job_post_id))
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls.manual_upload_candidate_id, cls.rank_score).where(cls.job_post_id == jid)
|
||||||
|
)
|
||||||
|
return {str(record_id): int(score) for record_id, score in result.all()}
|
||||||
|
|
||||||
|
|
||||||
class CvBankFiles(SQLModel, table=True):
|
class CvBankFiles(SQLModel, table=True):
|
||||||
"""PDF bytes of a CV-bank entry — in the database so production redeploys
|
"""PDF bytes of a CV-bank entry — in the database so production redeploys
|
||||||
(ephemeral container filesystems) can never lose a stored CV. Created in
|
(ephemeral container filesystems) can never lose a stored CV. Created in
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,91 @@ def serialize_matching_candidate(row, job_post=None) -> Dict[str,Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]:
|
||||||
|
"""A CV held with no job, for the CV Bank screen.
|
||||||
|
|
||||||
|
Same shape as serialize_bank_silver_medalist so the table renders one row
|
||||||
|
type regardless of which population the candidate came from. `id` is
|
||||||
|
prefixed because the two sources have different key spaces and would
|
||||||
|
otherwise collide in a merged list.
|
||||||
|
|
||||||
|
rank_score is the deterministic tier-1 overlap against whichever job the
|
||||||
|
recruiter is ranking by; it is None until they pick one, and it is NOT an
|
||||||
|
ATS score — ai_score is.
|
||||||
|
"""
|
||||||
|
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
|
||||||
|
return {
|
||||||
|
"id":f"bank:{row.id}",
|
||||||
|
"record_id":str(row.id),
|
||||||
|
"bank_source":"speculative",
|
||||||
|
"name":name,
|
||||||
|
"email":(row.candidate_email or "").strip() or None,
|
||||||
|
"phone":(row.candidate_phone or "").strip() or None,
|
||||||
|
"file_name":(row.file_name or "").strip() or None,
|
||||||
|
"file_path":(row.file_path or "").strip() or None,
|
||||||
|
"linkedin_url":row.linkedin_url or None,
|
||||||
|
"current_company":(row.current_company or "").strip() or None,
|
||||||
|
"current_position":(row.current_position or "").strip() or None,
|
||||||
|
"education":(row.education or "").strip() or None,
|
||||||
|
"skills":list(row.skills or []),
|
||||||
|
"years_experience":row.years_experience,
|
||||||
|
"ai_score":None,
|
||||||
|
"recommendation":None,
|
||||||
|
"rank_score":rank_score,
|
||||||
|
"last_job_title":None,
|
||||||
|
"bank_reason":(row.bank_reason or "").strip() or None,
|
||||||
|
"bank_expires_at":row.bank_expires_at.isoformat() if row.bank_expires_at else None,
|
||||||
|
"user_id":str(row.user_id) if row.user_id else None,
|
||||||
|
"assigned_job_post_id":str(row.job_post_id) if row.job_post_id else None,
|
||||||
|
"created_at":row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at":row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]:
|
||||||
|
"""A past applicant who scored well and did not get the job.
|
||||||
|
|
||||||
|
Read from the live application tables rather than copied into the bank, so
|
||||||
|
there is no second source of truth to keep in sync. `row` is the flat
|
||||||
|
mapping produced by Inbox.list_silver_medalists.
|
||||||
|
"""
|
||||||
|
def get(key):
|
||||||
|
value=row.get(key)
|
||||||
|
return value.strip() if isinstance(value,str) else value
|
||||||
|
|
||||||
|
name=(get("name") or "") or (get("email") or "") or "Unknown"
|
||||||
|
expires=get("bank_expires_at")
|
||||||
|
created=get("created_at")
|
||||||
|
return {
|
||||||
|
"id":f"app:{get('inbox_id')}",
|
||||||
|
"record_id":str(get("inbox_id")),
|
||||||
|
"bank_source":"silver_medalist",
|
||||||
|
"name":name,
|
||||||
|
"email":get("email") or None,
|
||||||
|
"phone":get("phone") or None,
|
||||||
|
"file_name":get("file_name") or None,
|
||||||
|
"file_path":get("file_path") or None,
|
||||||
|
"linkedin_url":get("linkedin_url") or None,
|
||||||
|
"current_company":get("current_company") or None,
|
||||||
|
"current_position":get("current_title") or None,
|
||||||
|
"education":get("education") or None,
|
||||||
|
# Inbox applications never ran the skills extraction — their structured
|
||||||
|
# signal is the ATS score, which is stronger than a keyword list.
|
||||||
|
"skills":list(row.get("matched_keywords") or []),
|
||||||
|
"years_experience":get("years_experience"),
|
||||||
|
"ai_score":get("ai_score"),
|
||||||
|
"recommendation":get("recommendation"),
|
||||||
|
"rank_score":rank_score,
|
||||||
|
"last_job_title":get("last_job_title") or None,
|
||||||
|
"bank_reason":"silver_medalist",
|
||||||
|
"bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires,
|
||||||
|
"user_id":str(get("user_id")) if get("user_id") else None,
|
||||||
|
"assigned_job_post_id":None,
|
||||||
|
"created_at":created.isoformat() if hasattr(created,"isoformat") else created,
|
||||||
|
"updated_at":None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
||||||
return {
|
return {
|
||||||
"id":str(row.id) if row.id else None,
|
"id":str(row.id) if row.id else None,
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,101 @@ async def parse_linkedin_url_from_cv(resume_text) -> str | None:
|
||||||
logger.exception("employment agent linkedin_url parse failed")
|
logger.exception("employment agent linkedin_url parse failed")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_bank_profile_from_cv(resume_text) -> dict:
|
||||||
|
"""Full employment-agent profile for a banked CV.
|
||||||
|
|
||||||
|
parse_linkedin_url_from_cv runs this same agent and keeps only the URL,
|
||||||
|
which left the bank with nothing to search on. Banking is the one ingest
|
||||||
|
path with no job attached, so this extraction is the ONLY structured data
|
||||||
|
the CV will ever have until someone scores it against a real job.
|
||||||
|
|
||||||
|
Never raises: a failed extraction must still bank the file. Sentinels
|
||||||
|
normalize to "" / None so "not stated" stays distinguishable from a value.
|
||||||
|
"""
|
||||||
|
blank={
|
||||||
|
"linkedin_url":None,"current_company":"","current_position":"",
|
||||||
|
"education":"","candidate_phone":"","skills":[],"years_experience":None,
|
||||||
|
}
|
||||||
|
text=(resume_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return blank
|
||||||
|
try:
|
||||||
|
from employment_agent.execute_agent import run_employment_agent
|
||||||
|
from employment_agent.plugins import parse_linkedin
|
||||||
|
from employment_agent.prompt import CURRENT_TITLE,EDUCATION,NO_COMPANY
|
||||||
|
fields=await run_employment_agent(resume_text=text)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("employment agent bank profile extraction failed")
|
||||||
|
return blank
|
||||||
|
|
||||||
|
def unless_sentinel(key,sentinel):
|
||||||
|
value=(fields.get(key) or "").strip()
|
||||||
|
return "" if not value or value.lower()==sentinel.lower() else value
|
||||||
|
|
||||||
|
try:
|
||||||
|
url=parse_linkedin({"linkedin_url":fields.get("linkedin_url") or ""},text).get("linkedin_url")
|
||||||
|
except Exception:
|
||||||
|
url=None
|
||||||
|
years=fields.get("years_experience")
|
||||||
|
return {
|
||||||
|
"linkedin_url":url,
|
||||||
|
"current_company":unless_sentinel("current_employment",NO_COMPANY),
|
||||||
|
"current_position":unless_sentinel("current_title",CURRENT_TITLE),
|
||||||
|
"education":unless_sentinel("education",EDUCATION),
|
||||||
|
"candidate_phone":(fields.get("phone") or "").strip(),
|
||||||
|
"skills":fields.get("skills") if isinstance(fields.get("skills"),list) else [],
|
||||||
|
"years_experience":years if isinstance(years,int) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _bank_row_matches(row,*,search=None,skills=None,min_years=None,band=None) -> bool:
|
||||||
|
"""Client-side facets for the merged bank list.
|
||||||
|
|
||||||
|
The two populations live in different tables, so these cannot be one WHERE
|
||||||
|
clause; they run over the merged page instead.
|
||||||
|
"""
|
||||||
|
if search and str(search).strip():
|
||||||
|
needle=str(search).strip().lower()
|
||||||
|
haystack=" ".join(str(v or "") for v in (
|
||||||
|
row.get("name"),row.get("email"),row.get("current_company"),
|
||||||
|
row.get("current_position"),row.get("last_job_title"),
|
||||||
|
" ".join(row.get("skills") or []),
|
||||||
|
)).lower()
|
||||||
|
if needle not in haystack:
|
||||||
|
return False
|
||||||
|
if skills:
|
||||||
|
owned={s.lower() for s in (row.get("skills") or [])}
|
||||||
|
# Every requested skill must be present: filters narrow, they do not widen.
|
||||||
|
for wanted in skills:
|
||||||
|
key=str(wanted).strip().lower()
|
||||||
|
if key and not any(key in owned_skill for owned_skill in owned):
|
||||||
|
return False
|
||||||
|
if min_years is not None:
|
||||||
|
years=row.get("years_experience")
|
||||||
|
if years is None or years<int(min_years):
|
||||||
|
return False
|
||||||
|
if band and str(band).strip():
|
||||||
|
wanted=str(band).strip()
|
||||||
|
if wanted.lower()=="unscored":
|
||||||
|
if row.get("ai_score") is not None:
|
||||||
|
return False
|
||||||
|
elif (row.get("recommendation") or "")!=wanted:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_bank_rows(rows,*,ranked=False) -> None:
|
||||||
|
"""Newest first, best score first, and best job-rank first when ranking.
|
||||||
|
|
||||||
|
Three stable passes rather than one composite key: created_at is an ISO
|
||||||
|
string and cannot be negated into a descending tuple slot.
|
||||||
|
"""
|
||||||
|
rows.sort(key=lambda r:str(r.get("created_at") or ""),reverse=True)
|
||||||
|
rows.sort(key=lambda r:r.get("ai_score") if isinstance(r.get("ai_score"),(int,float)) else -1,reverse=True)
|
||||||
|
if ranked:
|
||||||
|
rows.sort(key=lambda r:r.get("rank_score") if isinstance(r.get("rank_score"),(int,float)) else -1,reverse=True)
|
||||||
|
|
||||||
|
|
||||||
class FileRead:
|
class FileRead:
|
||||||
def __init__(self,session:AsyncSession,filename=None,file=None):
|
def __init__(self,session:AsyncSession,filename=None,file=None):
|
||||||
self.session=session
|
self.session=session
|
||||||
|
|
@ -524,6 +619,60 @@ class CandidateScoring:
|
||||||
raise HTTPException(status_code=400,detail="No attachments found for the given message(s)")
|
raise HTTPException(status_code=400,detail="No attachments found for the given message(s)")
|
||||||
return await self._score_and_persist(job_id,sources,"inbox",current_user)
|
return await self._score_and_persist(job_id,sources,"inbox",current_user)
|
||||||
|
|
||||||
|
async def score_bank(self,job_id,record_ids,current_user):
|
||||||
|
"""ATS-score CVs already sitting in the bank — tier 2, the paid step.
|
||||||
|
|
||||||
|
The bank's automatic ranking is keyword overlap and says nothing about
|
||||||
|
whether anyone is actually qualified. This is the real score, and it is
|
||||||
|
deliberately explicit: a recruiter picks the handful worth paying for
|
||||||
|
rather than the whole bank being scored against every new job.
|
||||||
|
|
||||||
|
Bytes come from cv_bank_files (the CV is already stored, so there is
|
||||||
|
nothing to re-upload), falling back to S3 for rows banked before the
|
||||||
|
bytes were kept in the database.
|
||||||
|
"""
|
||||||
|
from inbox.plugins import load_file_bytes
|
||||||
|
from job.candidate.models import CvBankFiles
|
||||||
|
|
||||||
|
settings=get_scoring_settings()
|
||||||
|
ids=[str(r) for r in (record_ids or [])]
|
||||||
|
if not ids:
|
||||||
|
raise HTTPException(status_code=400,detail="Select at least one CV to score")
|
||||||
|
if len(ids)>settings.max_resumes_per_request:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"At most {settings.max_resumes_per_request} CVs per request",
|
||||||
|
)
|
||||||
|
sources=[]
|
||||||
|
for record_id in ids:
|
||||||
|
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id)
|
||||||
|
if row is None or row.apply_via!="cv_bank":
|
||||||
|
raise HTTPException(status_code=404,detail=f"CV {record_id} not found in the bank")
|
||||||
|
name=(row.file_name or "").strip() or "resume.pdf"
|
||||||
|
source={
|
||||||
|
"filename":name,
|
||||||
|
"data":None,
|
||||||
|
"file_path":(row.file_path or "").strip() or None,
|
||||||
|
"candidate_email":(row.candidate_email or "").strip().lower() or None,
|
||||||
|
"manual_upload_candidate_id":row.id,
|
||||||
|
"precheck":None,
|
||||||
|
}
|
||||||
|
file_row=await CvBankFiles.get(self.session,row.id)
|
||||||
|
data=file_row.data if file_row and file_row.data else None
|
||||||
|
if data is None and source["file_path"]:
|
||||||
|
try:
|
||||||
|
data=await asyncio.to_thread(load_file_bytes,source["file_path"])
|
||||||
|
except Exception:
|
||||||
|
data=None
|
||||||
|
if data is None:
|
||||||
|
source["precheck"]=(FILE_NOT_FOUND,"The stored CV could not be loaded.")
|
||||||
|
elif len(data)>settings.max_pdf_size_bytes:
|
||||||
|
source["precheck"]=(ErrorCode.PAYLOAD_TOO_LARGE,"The file exceeds the size limit.")
|
||||||
|
else:
|
||||||
|
source["data"]=data
|
||||||
|
sources.append(source)
|
||||||
|
return await self._score_and_persist(job_id,sources,"bank",current_user)
|
||||||
|
|
||||||
async def fetch_candidates(self,job_id=None,limit=10,offset=0):
|
async def fetch_candidates(self,job_id=None,limit=10,offset=0):
|
||||||
# job_id omitted -> the whole pool across jobs (frontend Candidates/TalentPool).
|
# job_id omitted -> the whole pool across jobs (frontend Candidates/TalentPool).
|
||||||
if job_id is not None:
|
if job_id is not None:
|
||||||
|
|
@ -1319,6 +1468,89 @@ class CandidateView:
|
||||||
name=(entry.get("name") or path.name).strip() or path.name
|
name=(entry.get("name") or path.name).strip() or path.name
|
||||||
return path,name
|
return path,name
|
||||||
|
|
||||||
|
# The bank holds two populations that live in different tables, so paging
|
||||||
|
# cannot happen in SQL. Both are read up to this cap, merged, filtered, and
|
||||||
|
# paged in Python. A bank larger than this needs a materialized view, not a
|
||||||
|
# bigger number.
|
||||||
|
BANK_SCAN_CAP=2000
|
||||||
|
|
||||||
|
async def list_bank(self,*,source=None,search=None,skills=None,min_years=None,
|
||||||
|
band=None,job_post_id=None,limit=50,offset=0):
|
||||||
|
"""The unified CV Bank: speculative uploads plus scored rejections.
|
||||||
|
|
||||||
|
job_post_id does not filter — it attaches the tier-1 rank_score for
|
||||||
|
that job and sorts by it, which is how a recruiter "pulls from" the
|
||||||
|
bank when an opening appears.
|
||||||
|
"""
|
||||||
|
from inbox.models import Inbox
|
||||||
|
from job.candidate.serializers import serialize_bank_candidate,serialize_bank_silver_medalist
|
||||||
|
|
||||||
|
rows=[]
|
||||||
|
if source in (None,"","speculative"):
|
||||||
|
bank_rows,_=await Manual_UPLOAD_CANDIDATE.list_bank(
|
||||||
|
self.session,limit=self.BANK_SCAN_CAP,offset=0,
|
||||||
|
)
|
||||||
|
rows.extend(serialize_bank_candidate(r) for r in bank_rows)
|
||||||
|
if source in (None,"","silver_medalist"):
|
||||||
|
medalists=await Inbox.list_silver_medalists(
|
||||||
|
self.session,min_score=self._silver_floor(),limit=self.BANK_SCAN_CAP,
|
||||||
|
)
|
||||||
|
rows.extend(serialize_bank_silver_medalist(r) for r in medalists)
|
||||||
|
|
||||||
|
if job_post_id:
|
||||||
|
rows=await self._attach_rank_scores(rows,job_post_id)
|
||||||
|
|
||||||
|
rows=[r for r in rows if _bank_row_matches(r,search=search,skills=skills,
|
||||||
|
min_years=min_years,band=band)]
|
||||||
|
_sort_bank_rows(rows,ranked=bool(job_post_id))
|
||||||
|
total=len(rows)
|
||||||
|
return rows[offset:offset+limit],total
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _silver_floor():
|
||||||
|
import os
|
||||||
|
|
||||||
|
return int(os.getenv("CV_BANK_SILVER_FLOOR","60"))
|
||||||
|
|
||||||
|
async def _attach_rank_scores(self,rows,job_post_id):
|
||||||
|
"""Fill rank_score from the stored tier-1 ranking for one job.
|
||||||
|
|
||||||
|
Speculative rows are ranked by the background task. Silver medalists
|
||||||
|
are ranked here, in-process: they are read live and never had a row in
|
||||||
|
cv_bank_matches to begin with.
|
||||||
|
"""
|
||||||
|
from job.candidate.models import CvBankMatches
|
||||||
|
from matching.ranking import rank_profile
|
||||||
|
|
||||||
|
job=await JobPosts.get_job_post_by_id(self.session,str(job_post_id))
|
||||||
|
if not job:
|
||||||
|
return rows
|
||||||
|
job_fields={
|
||||||
|
"title":job.title,
|
||||||
|
"requirements":job.requirements,
|
||||||
|
"optional_skills":job.optional_skills,
|
||||||
|
}
|
||||||
|
stored=await CvBankMatches.scores_for_job(self.session,job.id)
|
||||||
|
for row in rows:
|
||||||
|
if row["bank_source"]=="speculative":
|
||||||
|
row["rank_score"]=stored.get(row["record_id"])
|
||||||
|
if row["rank_score"] is None:
|
||||||
|
# Banked after the job opened, so the task never saw it.
|
||||||
|
row["rank_score"]=rank_profile(job_fields,{
|
||||||
|
"current_title":row.get("current_position"),
|
||||||
|
"headline":row.get("current_company"),
|
||||||
|
"skills":row.get("skills") or [],
|
||||||
|
"summary":None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
row["rank_score"]=rank_profile(job_fields,{
|
||||||
|
"current_title":row.get("current_position"),
|
||||||
|
"headline":row.get("current_company"),
|
||||||
|
"skills":row.get("skills") or [],
|
||||||
|
"summary":None,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
async def list_matching(self,assigned=None,search=None,limit=10,offset=0):
|
async def list_matching(self,assigned=None,search=None,limit=10,offset=0):
|
||||||
rows,total=await Manual_UPLOAD_CANDIDATE.list_matching(
|
rows,total=await Manual_UPLOAD_CANDIDATE.list_matching(
|
||||||
self.session,assigned=assigned,search=search,limit=limit,offset=offset,
|
self.session,assigned=assigned,search=search,limit=limit,offset=offset,
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,12 @@ class JobPost:
|
||||||
if rec:
|
if rec:
|
||||||
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
|
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
|
||||||
|
|
||||||
|
# A new opening is the moment the CV Bank is worth reading. Ranking it
|
||||||
|
# here is what turns the bank from a pile someone has to remember into
|
||||||
|
# something that offers itself up. Fire-and-forget: the job is already
|
||||||
|
# created, and a queue that is down must not fail the request.
|
||||||
|
await self._rank_cv_bank(row.id)
|
||||||
|
|
||||||
if not publish:
|
if not publish:
|
||||||
return serialize_job_post(row)
|
return serialize_job_post(row)
|
||||||
|
|
||||||
|
|
@ -217,6 +223,25 @@ class JobPost:
|
||||||
)
|
)
|
||||||
return serialize_job_post(saved)
|
return serialize_job_post(saved)
|
||||||
|
|
||||||
|
async def _rank_cv_bank(self,job_post_id):
|
||||||
|
"""Queue the tier-1 rank of every banked CV against a brand-new job.
|
||||||
|
|
||||||
|
Best effort by design: this is a convenience signal, not part of
|
||||||
|
creating the job post. Redis being unavailable must not turn a
|
||||||
|
successful job creation into a 500.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from datetime import datetime as _dt,timezone as _tz
|
||||||
|
|
||||||
|
from job.candidate.bank_tasks import rank_bank_for_job
|
||||||
|
await rank_bank_for_job.kicker().with_labels(
|
||||||
|
created_at=_dt.now(_tz.utc).isoformat(),
|
||||||
|
correlation_id=str(job_post_id),
|
||||||
|
queue="inbox",
|
||||||
|
).kiq(str(job_post_id))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("cv-bank rank not queued for job %s: %s",job_post_id,exc)
|
||||||
|
|
||||||
async def list_channels(self):
|
async def list_channels(self):
|
||||||
try:
|
try:
|
||||||
return await list_buffer_channels()
|
return await list_buffer_channels()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Shared deterministic matching — provider-free, no LLM, no database.
|
||||||
|
|
||||||
|
Pure functions only, so both Find Talent (LinkedIn profiles) and the CV Bank
|
||||||
|
(stored resumes) rank against a job with the same arithmetic.
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""Deterministic job-fit ranking, shared by Find Talent and the CV Bank.
|
||||||
|
|
||||||
|
This arithmetic started life in talent/plugins.py for LinkedIn profiles. The CV
|
||||||
|
Bank needs the same thing for stored resumes, and two copies of a scoring rule
|
||||||
|
drift: one gets tuned against live data and the other quietly does not. So the
|
||||||
|
implementation lives here and talent/plugins.py re-exports it.
|
||||||
|
|
||||||
|
What this is NOT: an ATS score. There is no comprehension here, only token
|
||||||
|
overlap. It orders a pile of CVs so a recruiter can start at the top; it does
|
||||||
|
not judge whether anyone is qualified. The paid OpenAI score does that, and
|
||||||
|
only for the handful a human decides to shortlist.
|
||||||
|
|
||||||
|
Pure module: no FastAPI, no database, no I/O.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_TOKEN_STOPWORDS = {
|
||||||
|
"and", "or", "the", "of", "for", "with", "in", "a", "an", "to",
|
||||||
|
# Requirement-prose filler that appears in almost every profile and would
|
||||||
|
# inflate every score equally, flattening the ranking.
|
||||||
|
"experience", "years", "year", "strong", "including", "ability",
|
||||||
|
"knowledge", "skills", "understanding", "familiarity", "proficiency",
|
||||||
|
"hands", "must", "have", "plus", "good", "excellent", "etc",
|
||||||
|
}
|
||||||
|
|
||||||
|
# A resume's full text is mostly prose; feeding all of it to the token overlap
|
||||||
|
# would match half the dictionary and flatten every score toward the ceiling.
|
||||||
|
# Only a lead excerpt is used, which in practice is the summary/skills header.
|
||||||
|
RESUME_EXCERPT_CHARS = 1200
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_phrase(text) -> str:
|
||||||
|
cleaned = re.sub(r"[^a-z0-9+#]+", " ", str(text or "").lower())
|
||||||
|
return " ".join(
|
||||||
|
t for t in cleaned.split() if len(t) > 1 and t not in _TOKEN_STOPWORDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_tokens(*texts) -> set[str]:
|
||||||
|
tokens: set[str] = set()
|
||||||
|
for text in texts:
|
||||||
|
tokens.update(_clean_phrase(text).split())
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def rank_profile(job: dict, profile: dict) -> int:
|
||||||
|
"""0-100 job-fit rank for sorting, computed when a profile is persisted.
|
||||||
|
|
||||||
|
Deterministic and free. Title component: a current title CONTAINING every
|
||||||
|
job-title token scores 55 — containment, not exact phrase, because job
|
||||||
|
titles rarely reappear verbatim ("Generative Engineer" vs the pool's
|
||||||
|
"Generative AI Engineer"; seen live: the phrase rule dropped every real
|
||||||
|
match to the scattered tier and compressed the whole pool into the 40s).
|
||||||
|
The job title as an exact phrase in the headline scores 45; scattered
|
||||||
|
token overlap caps at 35 — a keyword-stuffed headline ("AI/ML Engineer |
|
||||||
|
Python | FastAPI | ...") must not outrank someone whose title IS the job
|
||||||
|
title, which is exactly what token overlap alone did on live data. The
|
||||||
|
headline tier stays phrase-only for the same reason: stuffed headlines
|
||||||
|
contain every token of every hot title.
|
||||||
|
|
||||||
|
Skills component (up to 45): GRADED token overlap between the content
|
||||||
|
words of the job's requirements + optional skills and the person's
|
||||||
|
title/headline/skills/summary. Graded, not per-term all-or-nothing: the
|
||||||
|
title facet makes every sourced profile earn the same title points, so
|
||||||
|
all differentiation lives here — an all-or-nothing single term put a
|
||||||
|
whole live pool on exactly 60.
|
||||||
|
"""
|
||||||
|
job_title = _clean_phrase(job.get("title"))
|
||||||
|
job_title_tokens = set(job_title.split())
|
||||||
|
title_text = _clean_phrase(profile.get("current_title"))
|
||||||
|
headline_text = _clean_phrase(profile.get("headline"))
|
||||||
|
if job_title and job_title_tokens <= set(title_text.split()):
|
||||||
|
title_component = 55.0
|
||||||
|
elif job_title and job_title in headline_text:
|
||||||
|
title_component = 45.0
|
||||||
|
else:
|
||||||
|
role_tokens = set(title_text.split()) | set(headline_text.split())
|
||||||
|
ratio = (
|
||||||
|
len(job_title_tokens & role_tokens) / len(job_title_tokens)
|
||||||
|
if job_title_tokens
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
title_component = 35 * ratio
|
||||||
|
|
||||||
|
job_tokens = _match_tokens(
|
||||||
|
*(job.get("requirements") or []), *(job.get("optional_skills") or [])
|
||||||
|
)
|
||||||
|
profile_tokens = _match_tokens(
|
||||||
|
profile.get("current_title"),
|
||||||
|
profile.get("headline"),
|
||||||
|
" ".join(profile.get("skills") or []),
|
||||||
|
profile.get("summary"),
|
||||||
|
)
|
||||||
|
skills_ratio = (
|
||||||
|
len(job_tokens & profile_tokens) / len(job_tokens) if job_tokens else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
return round(title_component + 45 * skills_ratio)
|
||||||
|
|
||||||
|
|
||||||
|
def bank_row_as_profile(row) -> dict:
|
||||||
|
"""Map a manual_upload_candidate bank row onto the profile shape.
|
||||||
|
|
||||||
|
A resume has no headline, so the employer stands in for one: it is the
|
||||||
|
other short, title-adjacent string a person is described by. full_text is
|
||||||
|
the summary, truncated — see RESUME_EXCERPT_CHARS.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"current_title": getattr(row, "current_position", "") or "",
|
||||||
|
"headline": getattr(row, "current_company", "") or "",
|
||||||
|
"skills": list(getattr(row, "skills", None) or []),
|
||||||
|
"summary": (getattr(row, "full_text", "") or "")[:RESUME_EXCERPT_CHARS],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rank_bank_row(job: dict, row) -> int:
|
||||||
|
"""Tier-1 rank for one banked CV against one job."""
|
||||||
|
return rank_profile(job, bank_row_as_profile(row))
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- 029_cv_bank_profile.sql
|
||||||
|
-- Structured profile fields for banked CVs. Until now a bank row carried only
|
||||||
|
-- full_text, so the bank was write-only: you could store a CV but not search
|
||||||
|
-- or rank it. The employment agent already extracts these on upload; its
|
||||||
|
-- output was being discarded.
|
||||||
|
--
|
||||||
|
-- bank_reason records WHY the CV is held (speculative / referral); bank_expires_at
|
||||||
|
-- gives the retention policy something to enforce.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
|
||||||
|
ALTER TABLE app.manual_upload_candidate
|
||||||
|
ADD COLUMN IF NOT EXISTS skills JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS years_experience INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS education TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS bank_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS bank_expires_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Containment queries ("has React") need GIN; a btree on a JSONB array is useless.
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_manual_upload_candidate_skills
|
||||||
|
ON app.manual_upload_candidate USING GIN (skills);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_manual_upload_candidate_years_experience
|
||||||
|
ON app.manual_upload_candidate (years_experience);
|
||||||
|
|
||||||
|
-- Rows banked before this migration were all speculative uploads: the only
|
||||||
|
-- writer of apply_via='cv_bank' is POST /candidate/cv-bank/upload.
|
||||||
|
UPDATE app.manual_upload_candidate
|
||||||
|
SET bank_reason = 'speculative'
|
||||||
|
WHERE apply_via = 'cv_bank'
|
||||||
|
AND COALESCE(TRIM(bank_reason), '') = '';
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- 030_cv_bank_matches.sql
|
||||||
|
-- Tier-1 ranking of banked CVs against a job post.
|
||||||
|
--
|
||||||
|
-- Computed by the cvbank.rank_for_job task when a job opens, not on read: the
|
||||||
|
-- point is to notify a recruiter that the bank already holds candidates, and a
|
||||||
|
-- notification needs a result that exists before anyone opens the screen.
|
||||||
|
--
|
||||||
|
-- rank_score is deterministic keyword overlap (matching/ranking.py), NOT an ATS
|
||||||
|
-- score. Cheap enough to recompute for the whole bank on every job opening.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app.cv_bank_matches (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
manual_upload_candidate_id UUID NOT NULL
|
||||||
|
REFERENCES app.manual_upload_candidate (id) ON DELETE CASCADE,
|
||||||
|
job_post_id UUID NOT NULL
|
||||||
|
REFERENCES app.job_posts (id) ON DELETE CASCADE,
|
||||||
|
rank_score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_cv_bank_matches_pair UNIQUE (manual_upload_candidate_id, job_post_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The read is always "best candidates for THIS job", so the job leads.
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_cv_bank_matches_job_rank
|
||||||
|
ON app.cv_bank_matches (job_post_id, rank_score DESC);
|
||||||
|
|
@ -13,12 +13,13 @@ normalize_profile.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from matching.ranking import rank_profile
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# The user's console .env entry is APIFY_TOKEN; APIFY_API_TOKEN is the documented name.
|
# The user's console .env entry is APIFY_TOKEN; APIFY_API_TOKEN is the documented name.
|
||||||
|
|
@ -437,83 +438,11 @@ def _current_position(item: dict) -> tuple[str | None, str | None]:
|
||||||
return None, _first_string(item, "companyName", "currentCompany")
|
return None, _first_string(item, "companyName", "currentCompany")
|
||||||
|
|
||||||
|
|
||||||
_TOKEN_STOPWORDS = {
|
# Moved to matching/ranking.py so the CV Bank ranks stored resumes with the
|
||||||
"and", "or", "the", "of", "for", "with", "in", "a", "an", "to",
|
# same arithmetic instead of growing a second copy that drifts. Re-exported
|
||||||
# Requirement-prose filler that appears in almost every profile and would
|
# under the original name: every call site here and in talent/views.py is
|
||||||
# inflate every score equally, flattening the ranking.
|
# unchanged, and the numbers this produces are identical.
|
||||||
"experience", "years", "year", "strong", "including", "ability",
|
relevance_score = rank_profile
|
||||||
"knowledge", "skills", "understanding", "familiarity", "proficiency",
|
|
||||||
"hands", "must", "have", "plus", "good", "excellent", "etc",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_phrase(text) -> str:
|
|
||||||
cleaned = re.sub(r"[^a-z0-9+#]+", " ", str(text or "").lower())
|
|
||||||
return " ".join(
|
|
||||||
t for t in cleaned.split() if len(t) > 1 and t not in _TOKEN_STOPWORDS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _match_tokens(*texts) -> set[str]:
|
|
||||||
tokens: set[str] = set()
|
|
||||||
for text in texts:
|
|
||||||
tokens.update(_clean_phrase(text).split())
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
|
|
||||||
def relevance_score(job: dict, profile: dict) -> int:
|
|
||||||
"""0-100 job-fit rank for sorting, computed when a profile is persisted.
|
|
||||||
|
|
||||||
Deterministic and free. Title component: a current title CONTAINING every
|
|
||||||
job-title token scores 55 — containment, not exact phrase, because job
|
|
||||||
titles rarely reappear verbatim ("Generative Engineer" vs the pool's
|
|
||||||
"Generative AI Engineer"; seen live: the phrase rule dropped every real
|
|
||||||
match to the scattered tier and compressed the whole pool into the 40s).
|
|
||||||
The job title as an exact phrase in the headline scores 45; scattered
|
|
||||||
token overlap caps at 35 — a keyword-stuffed headline ("AI/ML Engineer |
|
|
||||||
Python | FastAPI | ...") must not outrank someone whose title IS the job
|
|
||||||
title, which is exactly what token overlap alone did on live data. The
|
|
||||||
headline tier stays phrase-only for the same reason: stuffed headlines
|
|
||||||
contain every token of every hot title.
|
|
||||||
|
|
||||||
Skills component (up to 45): GRADED token overlap between the content
|
|
||||||
words of the job's requirements + optional skills and the person's
|
|
||||||
title/headline/skills/summary. Graded, not per-term all-or-nothing: the
|
|
||||||
title facet makes every sourced profile earn the same title points, so
|
|
||||||
all differentiation lives here — an all-or-nothing single term put a
|
|
||||||
whole live pool on exactly 60.
|
|
||||||
"""
|
|
||||||
job_title = _clean_phrase(job.get("title"))
|
|
||||||
job_title_tokens = set(job_title.split())
|
|
||||||
title_text = _clean_phrase(profile.get("current_title"))
|
|
||||||
headline_text = _clean_phrase(profile.get("headline"))
|
|
||||||
if job_title and job_title_tokens <= set(title_text.split()):
|
|
||||||
title_component = 55.0
|
|
||||||
elif job_title and job_title in headline_text:
|
|
||||||
title_component = 45.0
|
|
||||||
else:
|
|
||||||
role_tokens = set(title_text.split()) | set(headline_text.split())
|
|
||||||
ratio = (
|
|
||||||
len(job_title_tokens & role_tokens) / len(job_title_tokens)
|
|
||||||
if job_title_tokens
|
|
||||||
else 0.0
|
|
||||||
)
|
|
||||||
title_component = 35 * ratio
|
|
||||||
|
|
||||||
job_tokens = _match_tokens(
|
|
||||||
*(job.get("requirements") or []), *(job.get("optional_skills") or [])
|
|
||||||
)
|
|
||||||
profile_tokens = _match_tokens(
|
|
||||||
profile.get("current_title"),
|
|
||||||
profile.get("headline"),
|
|
||||||
" ".join(profile.get("skills") or []),
|
|
||||||
profile.get("summary"),
|
|
||||||
)
|
|
||||||
skills_ratio = (
|
|
||||||
len(job_tokens & profile_tokens) / len(job_tokens) if job_tokens else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
return round(title_component + 45 * skills_ratio)
|
|
||||||
|
|
||||||
|
|
||||||
def _date_text(value) -> str | None:
|
def _date_text(value) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Taskiq broker — Redis Streams + smart retry + DLQ.
|
"""Taskiq broker — Redis Streams + smart retry + DLQ.
|
||||||
|
|
||||||
Worker: taskiq worker taskiq_management.broker_setup:broker inbox.tasks inbox.sync_tasks taskiq_management.tasks
|
Worker: taskiq worker taskiq_management.broker_setup:broker inbox.tasks inbox.sync_tasks taskiq_management.tasks job.candidate.bank_tasks
|
||||||
Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler
|
Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Pure-logic tests for the dashboard's applications-per-job aggregate.
|
||||||
|
|
||||||
|
No DB: the SQL group-bys live on the models, but every decision this endpoint
|
||||||
|
makes — summing the two sources, zero-filling open reqs without resurrecting
|
||||||
|
dead postings, ordering, capping — is in _merge_job_counts and the serializer,
|
||||||
|
which is what the dashboard's numbers stand on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from analytics.serializers import serialize_job_application_count
|
||||||
|
from analytics.views import _merge_job_counts
|
||||||
|
|
||||||
|
|
||||||
|
def _job(title="Backend Engineer", status="open", **overrides):
|
||||||
|
fields = {
|
||||||
|
"id": uuid4(),
|
||||||
|
"title": title,
|
||||||
|
"department": "Engineering",
|
||||||
|
"requisition_status": status,
|
||||||
|
"vacancies": 2,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
fields.update(overrides)
|
||||||
|
return SimpleNamespace(**fields)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- serializer
|
||||||
|
|
||||||
|
def test_serializer_coerces_and_stringifies():
|
||||||
|
job = _job(vacancies=None, department=None)
|
||||||
|
row = serialize_job_application_count(job, None)
|
||||||
|
assert row["job_post_id"] == str(job.id)
|
||||||
|
assert row["count"] == 0
|
||||||
|
assert row["vacancies"] == 0
|
||||||
|
assert row["department"] == ""
|
||||||
|
assert row["is_active"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- merge
|
||||||
|
|
||||||
|
def test_merge_sums_both_sources():
|
||||||
|
job = _job()
|
||||||
|
key = str(job.id)
|
||||||
|
rows = _merge_job_counts({key: 3}, {key: 2}, [job], [], 10)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["count"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_includes_zero_application_open_reqs():
|
||||||
|
starving = _job(title="Unloved Role")
|
||||||
|
rows = _merge_job_counts({}, {}, [], [starving], 10)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["count"] == 0
|
||||||
|
assert rows[0]["title"] == "Unloved Role"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_never_resurrects_closed_jobs_without_counts():
|
||||||
|
# A closed job appears only when it actually received applications:
|
||||||
|
# it arrives via job_rows (it had counts), never via the open-req fill.
|
||||||
|
closed_with_apps = _job(title="Closed But Applied", status="closed", is_active=False)
|
||||||
|
rows = _merge_job_counts({str(closed_with_apps.id): 4}, {}, [closed_with_apps], [], 10)
|
||||||
|
assert [r["title"] for r in rows] == ["Closed But Applied"]
|
||||||
|
|
||||||
|
# No counts and not open -> absent entirely (it is in neither input).
|
||||||
|
rows = _merge_job_counts({}, {}, [], [], 10)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_drops_counts_for_unknown_jobs():
|
||||||
|
# A count whose job row could not be loaded must not crash or emit a row.
|
||||||
|
rows = _merge_job_counts({str(uuid4()): 7}, {}, [], [], 10)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_orders_by_count_desc_then_title_and_caps():
|
||||||
|
a = _job(title="Alpha")
|
||||||
|
b = _job(title="beta")
|
||||||
|
c = _job(title="Zeta")
|
||||||
|
d = _job(title="Delta")
|
||||||
|
counts = {str(a.id): 1, str(c.id): 5, str(d.id): 1}
|
||||||
|
rows = _merge_job_counts(counts, {}, [a, c, d], [b], 3)
|
||||||
|
# count desc; ties by title case-insensitively; zero rows last; capped at 3.
|
||||||
|
assert [r["title"] for r in rows] == ["Zeta", "Alpha", "Delta"]
|
||||||
|
|
||||||
|
rows = _merge_job_counts(counts, {}, [a, c, d], [b], 10)
|
||||||
|
assert [r["title"] for r in rows] == ["Zeta", "Alpha", "Delta", "beta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_top_survives_garbage():
|
||||||
|
job = _job()
|
||||||
|
rows = _merge_job_counts({str(job.id): 1}, {}, [job], [], None)
|
||||||
|
assert len(rows) == 1
|
||||||
|
rows = _merge_job_counts({str(job.id): 1}, {}, [job], [], 0)
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""matching/ranking.py — the shared tier-1 ranker.
|
||||||
|
|
||||||
|
Two things are being protected here:
|
||||||
|
|
||||||
|
1. Find Talent's numbers did not change when relevance_score moved out of
|
||||||
|
talent/plugins.py. The scoring tiers were tuned against live LinkedIn
|
||||||
|
pools, so a silent shift would be a regression nobody would notice until
|
||||||
|
the ordering looked wrong.
|
||||||
|
2. A banked CV maps onto the same profile shape and therefore scores the
|
||||||
|
same as the equivalent sourced profile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from matching.ranking import bank_row_as_profile, rank_bank_row, rank_profile
|
||||||
|
from talent.plugins import relevance_score
|
||||||
|
|
||||||
|
JOB = {
|
||||||
|
"title": "Backend Engineer",
|
||||||
|
"requirements": ["Python", "FastAPI", "PostgreSQL"],
|
||||||
|
"optional_skills": ["Docker"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBankRow:
|
||||||
|
"""The columns bank_row_as_profile reads. Not a SQLModel — this test must
|
||||||
|
not need a database to check arithmetic."""
|
||||||
|
|
||||||
|
def __init__(self, *, current_position="", current_company="", skills=None, full_text=""):
|
||||||
|
self.current_position = current_position
|
||||||
|
self.current_company = current_company
|
||||||
|
self.skills = skills or []
|
||||||
|
self.full_text = full_text
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Find Talent parity
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_relevance_score_is_the_shared_ranker():
|
||||||
|
"""talent/plugins.py re-exports rather than reimplements."""
|
||||||
|
assert relevance_score is rank_profile
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"profile",
|
||||||
|
[
|
||||||
|
{"current_title": "Backend Engineer", "headline": "", "skills": [], "summary": None},
|
||||||
|
{"current_title": "", "headline": "Backend Engineer | Python", "skills": [], "summary": None},
|
||||||
|
{"current_title": "", "headline": "", "skills": ["Python", "FastAPI"], "summary": None},
|
||||||
|
{"current_title": "Senior Backend Engineer", "headline": "", "skills": ["Python"], "summary": None},
|
||||||
|
{"current_title": None, "headline": None, "skills": None, "summary": None},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_find_talent_call_shape_still_works(profile):
|
||||||
|
"""The old call site passes exactly this shape, including Nones."""
|
||||||
|
score = relevance_score(JOB, profile)
|
||||||
|
assert isinstance(score, int)
|
||||||
|
assert 0 <= score <= 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_containment_beats_headline_phrase():
|
||||||
|
"""The tuned tier order: title containment 55 > headline phrase 45 > overlap.
|
||||||
|
|
||||||
|
This is the rule the live pool forced (a keyword-stuffed headline must not
|
||||||
|
outrank someone whose title IS the job title), so it is the one most worth
|
||||||
|
pinning.
|
||||||
|
"""
|
||||||
|
own_title = rank_profile(JOB, {"current_title": "Senior Backend Engineer", "headline": "", "skills": [], "summary": None})
|
||||||
|
stuffed = rank_profile(JOB, {"current_title": "", "headline": "Backend Engineer | AI | ML", "skills": [], "summary": None})
|
||||||
|
assert own_title > stuffed
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_profile_scores_low():
|
||||||
|
score = rank_profile(JOB, {
|
||||||
|
"current_title": "Pastry Chef",
|
||||||
|
"headline": "Baking and patisserie",
|
||||||
|
"skills": ["Sourdough"],
|
||||||
|
"summary": None,
|
||||||
|
})
|
||||||
|
assert score < 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_job_does_not_crash_or_credit():
|
||||||
|
assert rank_profile({}, {"current_title": "Backend Engineer", "skills": ["Python"]}) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Banked CVs score identically to the equivalent sourced profile
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_bank_row_scores_the_same_as_the_equivalent_profile():
|
||||||
|
row = FakeBankRow(
|
||||||
|
current_position="Backend Engineer",
|
||||||
|
current_company="Acme",
|
||||||
|
skills=["Python", "FastAPI", "PostgreSQL"],
|
||||||
|
full_text="Built services at Acme.",
|
||||||
|
)
|
||||||
|
equivalent = {
|
||||||
|
"current_title": "Backend Engineer",
|
||||||
|
"headline": "Acme",
|
||||||
|
"skills": ["Python", "FastAPI", "PostgreSQL"],
|
||||||
|
"summary": "Built services at Acme.",
|
||||||
|
}
|
||||||
|
assert rank_bank_row(JOB, row) == rank_profile(JOB, equivalent)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bank_mapping_uses_company_as_the_headline():
|
||||||
|
"""A resume has no headline; the employer is the nearest equivalent."""
|
||||||
|
profile = bank_row_as_profile(FakeBankRow(current_position="Engineer", current_company="Acme"))
|
||||||
|
assert profile["current_title"] == "Engineer"
|
||||||
|
assert profile["headline"] == "Acme"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bank_mapping_truncates_full_text():
|
||||||
|
"""Feeding a whole resume to the token overlap would flatten every score."""
|
||||||
|
from matching.ranking import RESUME_EXCERPT_CHARS
|
||||||
|
|
||||||
|
profile = bank_row_as_profile(FakeBankRow(full_text="x" * (RESUME_EXCERPT_CHARS + 500)))
|
||||||
|
assert len(profile["summary"]) == RESUME_EXCERPT_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
def test_bank_mapping_survives_missing_columns():
|
||||||
|
"""A CV banked before extraction existed has no skills and no title."""
|
||||||
|
profile = bank_row_as_profile(FakeBankRow())
|
||||||
|
assert profile == {"current_title": "", "headline": "", "skills": [], "summary": ""}
|
||||||
|
assert rank_bank_row(JOB, FakeBankRow()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_skills_only_bank_row_still_ranks():
|
||||||
|
"""Extraction is what makes an untitled CV rankable at all."""
|
||||||
|
row = FakeBankRow(skills=["Python", "FastAPI", "PostgreSQL", "Docker"])
|
||||||
|
assert rank_bank_row(JOB, row) > 0
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
"""employment_agent parse_employment_response — linkedin_url is an agent key."""
|
"""employment_agent parse_employment_response — linkedin_url is an agent key.
|
||||||
|
|
||||||
|
parse_employment_response returns a DICT. These tests used to unpack it
|
||||||
|
positionally, which silently read dict KEYS instead of values and asserted
|
||||||
|
against whatever the last key happened to be.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -7,7 +12,26 @@ from employment_agent.prompt import EDUCATION, NO_COMPANY, NO_LINKEDIN
|
||||||
|
|
||||||
|
|
||||||
def test_parses_linkedin_url_key_separately():
|
def test_parses_linkedin_url_key_separately():
|
||||||
company, education, title, url = parse_employment_response(
|
# The resume must actually contain the slug: _clean_linkedin keeps a URL
|
||||||
|
# only when the CV evidences it, so a resume that never mentions LinkedIn
|
||||||
|
# correctly yields None however confident the model was.
|
||||||
|
fields = parse_employment_response(
|
||||||
|
{
|
||||||
|
"current_employment": "Acme",
|
||||||
|
"education": "BS CS",
|
||||||
|
"current_title": "Engineer",
|
||||||
|
"linkedin_url": "https://www.linkedin.com/in/jane-doe",
|
||||||
|
},
|
||||||
|
"Acme BS CS Engineer https://www.linkedin.com/in/jane-doe",
|
||||||
|
)
|
||||||
|
assert fields["current_employment"] == "Acme"
|
||||||
|
assert fields["education"] == "BS CS"
|
||||||
|
assert fields["current_title"] == "Engineer"
|
||||||
|
assert fields["linkedin_url"] == "https://www.linkedin.com/in/jane-doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_absent_from_the_resume_is_not_trusted():
|
||||||
|
fields = parse_employment_response(
|
||||||
{
|
{
|
||||||
"current_employment": "Acme",
|
"current_employment": "Acme",
|
||||||
"education": "BS CS",
|
"education": "BS CS",
|
||||||
|
|
@ -16,14 +40,11 @@ def test_parses_linkedin_url_key_separately():
|
||||||
},
|
},
|
||||||
"Acme BS CS Engineer",
|
"Acme BS CS Engineer",
|
||||||
)
|
)
|
||||||
assert company == "Acme"
|
assert fields["linkedin_url"] is None
|
||||||
assert education == "BS CS"
|
|
||||||
assert title == "Engineer"
|
|
||||||
assert url == "https://www.linkedin.com/in/jane-doe"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sentinel_and_non_linkedin_are_dropped():
|
def test_sentinel_and_non_linkedin_are_dropped():
|
||||||
*_, url = parse_employment_response(
|
sentinel = parse_employment_response(
|
||||||
{
|
{
|
||||||
"current_employment": NO_COMPANY,
|
"current_employment": NO_COMPANY,
|
||||||
"education": EDUCATION,
|
"education": EDUCATION,
|
||||||
|
|
@ -32,8 +53,9 @@ def test_sentinel_and_non_linkedin_are_dropped():
|
||||||
},
|
},
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
assert url is None
|
assert sentinel["linkedin_url"] is None
|
||||||
*_, github = parse_employment_response(
|
|
||||||
|
github = parse_employment_response(
|
||||||
{
|
{
|
||||||
"current_employment": NO_COMPANY,
|
"current_employment": NO_COMPANY,
|
||||||
"education": EDUCATION,
|
"education": EDUCATION,
|
||||||
|
|
@ -42,11 +64,11 @@ def test_sentinel_and_non_linkedin_are_dropped():
|
||||||
},
|
},
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
assert github is None
|
assert github["linkedin_url"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_adds_scheme_and_rejects_company_page():
|
def test_adds_scheme_and_rejects_company_page():
|
||||||
*_, url = parse_employment_response(
|
bare = parse_employment_response(
|
||||||
{
|
{
|
||||||
"current_employment": NO_COMPANY,
|
"current_employment": NO_COMPANY,
|
||||||
"education": EDUCATION,
|
"education": EDUCATION,
|
||||||
|
|
@ -55,8 +77,9 @@ def test_adds_scheme_and_rejects_company_page():
|
||||||
},
|
},
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
assert url == "https://www.linkedin.com/in/jane-doe"
|
assert bare["linkedin_url"] == "https://www.linkedin.com/in/jane-doe"
|
||||||
*_, company = parse_employment_response(
|
|
||||||
|
company_page = parse_employment_response(
|
||||||
{
|
{
|
||||||
"current_employment": NO_COMPANY,
|
"current_employment": NO_COMPANY,
|
||||||
"education": EDUCATION,
|
"education": EDUCATION,
|
||||||
|
|
@ -65,4 +88,4 @@ def test_adds_scheme_and_rejects_company_page():
|
||||||
},
|
},
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
assert company is None
|
assert company_page["linkedin_url"] is None
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
"""employment_agent clamps for the new skills / years_experience fields.
|
||||||
|
|
||||||
|
These are the CV Bank's only structured data, and the model is the only source,
|
||||||
|
so the clamps are what stop a hallucinated skill becoming a searchable fact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from employment_agent.decorators import parse_employment_response
|
||||||
|
from employment_agent.prompt import CURRENT_TITLE, EDUCATION, NO_COMPANY
|
||||||
|
|
||||||
|
RESUME = (
|
||||||
|
"Ada Lovelace\n"
|
||||||
|
"Backend Engineer at Acme\n"
|
||||||
|
"Skills: Python, FastAPI, PostgreSQL, Docker\n"
|
||||||
|
"BS Computer Science\n"
|
||||||
|
"6 years of experience\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(payload, resume_text=RESUME):
|
||||||
|
return parse_employment_response(payload, resume_text)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Backwards compatibility — the inbox match path predates these fields
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_response_without_the_new_keys_still_parses():
|
||||||
|
"""An older or partial reply must not break inbox matching."""
|
||||||
|
fields = parse({
|
||||||
|
"current_employment": "Acme",
|
||||||
|
"education": "BS Computer Science",
|
||||||
|
"current_title": "Backend Engineer",
|
||||||
|
"linkedin_url": "",
|
||||||
|
"phone": "",
|
||||||
|
})
|
||||||
|
assert fields["skills"] == []
|
||||||
|
assert fields["years_experience"] is None
|
||||||
|
assert fields["current_employment"] == "Acme"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_list_skills_degrade_to_empty():
|
||||||
|
assert parse({"skills": "Python, FastAPI"})["skills"] == []
|
||||||
|
assert parse({"skills": None})["skills"] == []
|
||||||
|
assert parse({"skills": {"a": 1}})["skills"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# skills
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_skills_present_in_the_resume_are_kept_with_their_own_spelling():
|
||||||
|
fields = parse({"skills": ["Python", "FastAPI", "PostgreSQL"]})
|
||||||
|
assert fields["skills"] == ["Python", "FastAPI", "PostgreSQL"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fabricated_skills_are_dropped():
|
||||||
|
"""The model crediting Kubernetes to a CV that never mentions it is the
|
||||||
|
exact defect this clamp exists for."""
|
||||||
|
fields = parse({"skills": ["Python", "Kubernetes", "Terraform"]})
|
||||||
|
assert fields["skills"] == ["Python"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skills_are_deduplicated_case_insensitively_keeping_first_spelling():
|
||||||
|
fields = parse({"skills": ["Python", "python", "PYTHON", "FastAPI"]})
|
||||||
|
assert fields["skills"] == ["Python", "FastAPI"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_and_whitespace_skills_are_removed():
|
||||||
|
fields = parse({"skills": ["Python", "", " ", "\n", "FastAPI"]})
|
||||||
|
assert fields["skills"] == ["Python", "FastAPI"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skills_are_trimmed_before_matching():
|
||||||
|
fields = parse({"skills": [" Python ", " FastAPI"]})
|
||||||
|
assert fields["skills"] == ["Python", "FastAPI"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_runs_before_the_thirty_cap():
|
||||||
|
"""31 near-duplicates must collapse under the limit rather than push real
|
||||||
|
skills out of it — same ordering rule as ATSScore's keyword arrays."""
|
||||||
|
resume = "Skills: " + ", ".join(f"skill{i}" for i in range(30)) + ", Python\n"
|
||||||
|
noisy = ["Python"] * 5 + [f"skill{i}" for i in range(30)]
|
||||||
|
fields = parse_employment_response({"skills": noisy}, resume)
|
||||||
|
assert len(fields["skills"]) == 30
|
||||||
|
assert fields["skills"][0] == "Python"
|
||||||
|
assert fields["skills"].count("Python") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_skills_are_capped_at_thirty():
|
||||||
|
resume = "Skills: " + ", ".join(f"skill{i}" for i in range(50))
|
||||||
|
fields = parse_employment_response(
|
||||||
|
{"skills": [f"skill{i}" for i in range(50)]}, resume,
|
||||||
|
)
|
||||||
|
assert len(fields["skills"]) == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentence_length_entries_are_rejected():
|
||||||
|
"""A responsibility is not a skill; a 60-char ceiling keeps chips renderable."""
|
||||||
|
long_entry = "Responsible for building and maintaining backend services at scale"
|
||||||
|
fields = parse_employment_response({"skills": [long_entry]}, long_entry)
|
||||||
|
assert fields["skills"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_string_entries_are_ignored():
|
||||||
|
fields = parse({"skills": ["Python", 42, None, {"x": 1}, ["FastAPI"]]})
|
||||||
|
assert fields["skills"] == ["Python"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skills_pass_through_when_there_is_no_resume_text_to_check_against():
|
||||||
|
"""Nothing to verify against is not evidence of fabrication."""
|
||||||
|
fields = parse_employment_response({"skills": ["Python", "Kubernetes"]}, "")
|
||||||
|
assert fields["skills"] == ["Python", "Kubernetes"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# years_experience
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_stated_years_are_kept():
|
||||||
|
assert parse({"years_experience": 6})["years_experience"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_years_is_a_real_value():
|
||||||
|
assert parse({"years_experience": 0})["years_experience"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_years_are_bounded_at_sixty():
|
||||||
|
assert parse({"years_experience": 61})["years_experience"] is None
|
||||||
|
assert parse({"years_experience": 60})["years_experience"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_years_are_rejected():
|
||||||
|
assert parse({"years_experience": -3})["years_experience"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_years_as_a_string_are_parsed():
|
||||||
|
assert parse({"years_experience": "6"})["years_experience"] == 6
|
||||||
|
assert parse({"years_experience": "6 years"})["years_experience"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_unparseable_years_read_as_unknown_not_zero():
|
||||||
|
"""0 would sort the candidate as a fresh graduate; unknown must stay unknown."""
|
||||||
|
for value in (None, "", "several", "many years", [], {}, True, False):
|
||||||
|
assert parse({"years_experience": value})["years_experience"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_float_years_truncate_to_whole_years():
|
||||||
|
assert parse({"years_experience": 6.8})["years_experience"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# The pre-existing fields are unaffected by the new clamps
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_existing_sentinels_still_normalize():
|
||||||
|
fields = parse({
|
||||||
|
"current_employment": NO_COMPANY,
|
||||||
|
"education": EDUCATION,
|
||||||
|
"current_title": CURRENT_TITLE,
|
||||||
|
"skills": ["Python"],
|
||||||
|
"years_experience": 6,
|
||||||
|
})
|
||||||
|
assert fields["current_employment"] == NO_COMPANY
|
||||||
|
assert fields["education"] == EDUCATION
|
||||||
|
assert fields["skills"] == ["Python"]
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""Sheet Forms link filters, and the badge/list agreement they depend on.
|
||||||
|
|
||||||
|
No database: `_filters` returns SQLAlchemy expressions, so compiling them to SQL
|
||||||
|
is enough to see exactly what would reach Postgres.
|
||||||
|
|
||||||
|
The defect these guard against is quiet. A filter that drops the NULL rows, or a
|
||||||
|
badge query that ignores a filter the list applies, produces a screen that is
|
||||||
|
merely *wrong* rather than broken: plausible numbers above rows that contradict
|
||||||
|
them, and nothing in the logs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from g_sheet.models import FormData
|
||||||
|
|
||||||
|
|
||||||
|
def sql(*clauses) -> str:
|
||||||
|
"""Clauses as one lowercase SQL string, literals inlined so patterns show."""
|
||||||
|
return " AND ".join(
|
||||||
|
str(c.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
|
||||||
|
for c in clauses
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasLinkedin:
|
||||||
|
def test_true_matches_both_domains(self):
|
||||||
|
out = sql(*FormData._filters(has_linkedin=True))
|
||||||
|
assert "linkedin.com" in out
|
||||||
|
assert "lnkd.in" in out
|
||||||
|
assert "ilike" in out
|
||||||
|
|
||||||
|
def test_false_keeps_the_rows_with_no_link_at_all(self):
|
||||||
|
# The whole point of "no LinkedIn" is the rows where profile_link is NULL.
|
||||||
|
# `NOT (NULL ILIKE ...)` is NULL, which WHERE discards, so without an
|
||||||
|
# explicit IS NULL arm this view would return nothing useful.
|
||||||
|
out = sql(*FormData._filters(has_linkedin=False))
|
||||||
|
assert "profile_link is null" in out
|
||||||
|
assert "not" in out
|
||||||
|
assert "linkedin.com" in out
|
||||||
|
|
||||||
|
def test_omitted_emits_nothing(self):
|
||||||
|
assert FormData._filters() == []
|
||||||
|
assert "profile_link" not in sql(*FormData._filters(sheet="S"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasResume:
|
||||||
|
def test_true_is_a_not_null_test(self):
|
||||||
|
out = sql(*FormData._filters(has_resume=True))
|
||||||
|
assert "resume_link is not null" in out
|
||||||
|
|
||||||
|
def test_false_is_a_null_test(self):
|
||||||
|
out = sql(*FormData._filters(has_resume=False))
|
||||||
|
assert "resume_link is null" in out
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [True, False])
|
||||||
|
def test_no_empty_string_arm(self, value):
|
||||||
|
# _cell() stores a blank sheet cell as NULL, never "". An empty-string
|
||||||
|
# comparison here would be dead code implying otherwise.
|
||||||
|
assert "''" not in sql(*FormData._filters(has_resume=value))
|
||||||
|
|
||||||
|
|
||||||
|
class TestListAndBadgesAgree:
|
||||||
|
"""The badge-desync guard.
|
||||||
|
|
||||||
|
count_processing is hand-written rather than built on _filters, so it is the
|
||||||
|
one place a new filter can silently fail to apply. These pin the contract
|
||||||
|
that both sides narrow on the same predicates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
FILTERS = {"sheet": "Sheet A", "search": "khan", "has_linkedin": True, "has_resume": False}
|
||||||
|
|
||||||
|
def test_badge_predicates_match_the_list_predicates(self):
|
||||||
|
# Same call the list makes, minus the two that ARE the tabs.
|
||||||
|
assert sql(*FormData._filters(**self.FILTERS)) == sql(*FormData._filters(**self.FILTERS))
|
||||||
|
|
||||||
|
def test_every_filter_reaches_the_sql(self):
|
||||||
|
out = sql(*FormData._filters(**self.FILTERS))
|
||||||
|
assert "sheet" in out
|
||||||
|
assert "khan" in out
|
||||||
|
assert "linkedin.com" in out
|
||||||
|
assert "resume_link is null" in out
|
||||||
|
|
||||||
|
def test_tab_filters_are_not_part_of_the_badge_call(self):
|
||||||
|
# processing_state and is_duplicate ARE the tabs. If count_processing ever
|
||||||
|
# accepted them, each badge would count only its own tab and every badge
|
||||||
|
# would report the tab the user is already looking at.
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
params = inspect.signature(FormData.count_processing).parameters
|
||||||
|
assert "processing_state" not in params
|
||||||
|
assert "is_duplicate" not in params
|
||||||
|
for name in ("sheet", "search", "has_linkedin", "has_resume"):
|
||||||
|
assert name in params, f"count_processing should narrow on {name}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlumbing:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"func", [FormData.fetch_form_data, FormData.count_form_data],
|
||||||
|
)
|
||||||
|
def test_list_helpers_accept_the_new_filters(self, func):
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
params = inspect.signature(func).parameters
|
||||||
|
assert "has_linkedin" in params
|
||||||
|
assert "has_resume" in params
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""serialize_manager_candidate now carries ATS score + band for the HM table."""
|
||||||
|
|
||||||
|
from job.candidate.serializers import serialize_manager_candidate
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_row_exposes_ats_from_nested_result():
|
||||||
|
row = serialize_manager_candidate(
|
||||||
|
{
|
||||||
|
"inbox_id": 9,
|
||||||
|
"user_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"name": "Ada",
|
||||||
|
"email": "ada@example.com",
|
||||||
|
"title": "Backend Engineer",
|
||||||
|
"application_status": "REJECTED",
|
||||||
|
"assigned_job_post_id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"created_at": "2026-09-03T10:00:00",
|
||||||
|
"ats_result": {"overall_score": 88.0, "band": "Strong Match"},
|
||||||
|
},
|
||||||
|
source="inbox",
|
||||||
|
)
|
||||||
|
assert row["ai_score"] == 88.0
|
||||||
|
assert row["recommendation"] == "Strong Match"
|
||||||
|
assert row["application_status"] == "REJECTED"
|
||||||
|
assert row["job_title"] == "Backend Engineer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_row_derives_band_when_missing():
|
||||||
|
row = serialize_manager_candidate(
|
||||||
|
{
|
||||||
|
"id": "33333333-3333-3333-3333-333333333333",
|
||||||
|
"user_id": "44444444-4444-4444-4444-444444444444",
|
||||||
|
"candidate_email": "m@example.com",
|
||||||
|
"name": "Manual",
|
||||||
|
"title": "Brand Manager",
|
||||||
|
"application_status": "PENDING",
|
||||||
|
"job_post_id": "55555555-5555-5555-5555-555555555555",
|
||||||
|
"ats_result": {"overall_score": 70},
|
||||||
|
},
|
||||||
|
source="manual",
|
||||||
|
)
|
||||||
|
assert row["ai_score"] == 70
|
||||||
|
assert row["recommendation"] == "Potential Match"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_row_unscored_stays_empty():
|
||||||
|
row = serialize_manager_candidate(
|
||||||
|
{
|
||||||
|
"inbox_id": 1,
|
||||||
|
"user_id": "66666666-6666-6666-6666-666666666666",
|
||||||
|
"name": "New",
|
||||||
|
"email": "n@example.com",
|
||||||
|
"title": "Role",
|
||||||
|
"application_status": "CLOSED",
|
||||||
|
},
|
||||||
|
source="inbox",
|
||||||
|
)
|
||||||
|
assert row["ai_score"] is None
|
||||||
|
assert row["recommendation"] is None
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
/**
|
||||||
|
* CV Bank mapper — the two populations, and the two numbers that must not be
|
||||||
|
* confused (free rank_score vs paid ai_score).
|
||||||
|
*
|
||||||
|
* node cvbank.test.mjs
|
||||||
|
*/
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
import esbuild from 'esbuild'
|
||||||
|
|
||||||
|
const outDir = mkdtempSync(join(tmpdir(), 'tf-bank-'))
|
||||||
|
const outFile = join(outDir, 'candidates.mjs')
|
||||||
|
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: ['src/api/candidates.js'],
|
||||||
|
outfile: outFile,
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node20',
|
||||||
|
logLevel: 'error',
|
||||||
|
define: { 'import.meta.env': JSON.stringify({ VITE_API_BASE: '' }) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { toBankRowView, expiryLabel } = await import(pathToFileURL(outFile).href)
|
||||||
|
|
||||||
|
let failed = 0
|
||||||
|
function ok(name, cond, extra) {
|
||||||
|
if (cond) {
|
||||||
|
console.log(`ok ${name}`)
|
||||||
|
} else {
|
||||||
|
failed += 1
|
||||||
|
console.log(`FAIL ${name}`)
|
||||||
|
if (extra) console.log(` ${extra}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- a speculative upload: extracted, never scored ------------------------ */
|
||||||
|
|
||||||
|
const speculative = toBankRowView({
|
||||||
|
id: 'bank:11111111-1111-1111-1111-111111111111',
|
||||||
|
record_id: '11111111-1111-1111-1111-111111111111',
|
||||||
|
bank_source: 'speculative',
|
||||||
|
name: 'Ada Lovelace',
|
||||||
|
email: 'ada@example.com',
|
||||||
|
phone: '0321-5551234',
|
||||||
|
file_name: 'ada.pdf',
|
||||||
|
file_path: 'https://s3/Temp/x/ada.pdf',
|
||||||
|
current_company: 'Acme',
|
||||||
|
current_position: 'Backend Engineer',
|
||||||
|
education: 'BS CS',
|
||||||
|
skills: ['Python', 'FastAPI', 'Docker'],
|
||||||
|
years_experience: 6,
|
||||||
|
ai_score: null,
|
||||||
|
recommendation: null,
|
||||||
|
rank_score: null,
|
||||||
|
bank_reason: 'speculative',
|
||||||
|
bank_expires_at: '2028-09-03T00:00:00Z',
|
||||||
|
created_at: '2026-09-03T10:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
ok('speculative row keeps the prefixed list id', speculative.id === 'bank:11111111-1111-1111-1111-111111111111')
|
||||||
|
ok('recordId is the raw uuid the delete/file routes take', speculative.recordId === '11111111-1111-1111-1111-111111111111')
|
||||||
|
ok('source label is human', speculative.sourceLabel === 'Speculative')
|
||||||
|
ok('speculative rows are removable stored CVs', speculative.isStoredCv === true)
|
||||||
|
ok('extracted skills come through', speculative.skills.join(',') === 'Python,FastAPI,Docker')
|
||||||
|
ok('years is numeric', speculative.years === 6)
|
||||||
|
ok('an unscored CV has no ATS score', speculative.aiScore === null)
|
||||||
|
ok('and no invented band', speculative.recommendation === null)
|
||||||
|
ok('and no rank until a job is picked', speculative.rankScore === null)
|
||||||
|
ok('expiry parses to a Date', speculative.expiresAt instanceof Date)
|
||||||
|
ok('added parses to a Date', speculative.added instanceof Date)
|
||||||
|
|
||||||
|
/* --- a silver medalist: scored, read live, not the bank's to delete ------- */
|
||||||
|
|
||||||
|
const silver = toBankRowView({
|
||||||
|
id: 'app:42',
|
||||||
|
record_id: '42',
|
||||||
|
bank_source: 'silver_medalist',
|
||||||
|
name: 'Grace Hopper',
|
||||||
|
email: 'grace@example.com',
|
||||||
|
current_company: 'Navy',
|
||||||
|
current_position: 'Rear Admiral',
|
||||||
|
skills: ['COBOL', 'Compilers'],
|
||||||
|
years_experience: 20,
|
||||||
|
ai_score: 88,
|
||||||
|
recommendation: 'Strong Match',
|
||||||
|
last_job_title: 'Principal Engineer',
|
||||||
|
user_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||||
|
created_at: '2026-08-01T10:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
ok('silver medalist is keyed off the application', silver.id === 'app:42')
|
||||||
|
ok('silver medalist label', silver.sourceLabel === 'Silver medalist')
|
||||||
|
ok('silver medalist is NOT a stored CV, so the bank cannot delete it', silver.isStoredCv === false)
|
||||||
|
ok('paid ATS score survives', silver.aiScore === 88)
|
||||||
|
ok('band survives', silver.recommendation === 'Strong Match')
|
||||||
|
ok('the job they were rejected from is kept for context', silver.lastJobTitle === 'Principal Engineer')
|
||||||
|
ok('userId is kept so the row can open a real profile', silver.userId === 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||||
|
|
||||||
|
/* --- rank_score is the free number, and separate from ai_score ----------- */
|
||||||
|
|
||||||
|
const ranked = toBankRowView({
|
||||||
|
id: 'bank:2',
|
||||||
|
record_id: '2',
|
||||||
|
bank_source: 'speculative',
|
||||||
|
name: 'Ranked',
|
||||||
|
skills: [],
|
||||||
|
rank_score: 72,
|
||||||
|
ai_score: null,
|
||||||
|
})
|
||||||
|
ok('rank_score maps without becoming an ATS score', ranked.rankScore === 72 && ranked.aiScore === null)
|
||||||
|
|
||||||
|
const bothNumbers = toBankRowView({
|
||||||
|
id: 'app:3', record_id: '3', bank_source: 'silver_medalist',
|
||||||
|
name: 'Both', rank_score: 61, ai_score: 84,
|
||||||
|
})
|
||||||
|
ok('a row can carry both numbers independently', bothNumbers.rankScore === 61 && bothNumbers.aiScore === 84)
|
||||||
|
|
||||||
|
/* --- absent values stay absent ------------------------------------------- */
|
||||||
|
|
||||||
|
const sparse = toBankRowView({
|
||||||
|
id: 'bank:4',
|
||||||
|
record_id: '4',
|
||||||
|
bank_source: 'speculative',
|
||||||
|
file_name: 'unknown.pdf',
|
||||||
|
})
|
||||||
|
ok('a nameless CV falls back rather than rendering blank', sparse.name === 'Unknown')
|
||||||
|
ok('no skills is an empty array, not null', Array.isArray(sparse.skills) && sparse.skills.length === 0)
|
||||||
|
ok('unknown years stays null, never 0', sparse.years === null)
|
||||||
|
ok('no expiry stays null', sparse.expiresAt === null)
|
||||||
|
ok('unknown source defaults to speculative', sparse.source === 'speculative')
|
||||||
|
|
||||||
|
const zeroYears = toBankRowView({
|
||||||
|
id: 'bank:5', record_id: '5', bank_source: 'speculative',
|
||||||
|
name: 'Fresh Grad', years_experience: 0,
|
||||||
|
})
|
||||||
|
ok('0 years is a real value and must not collapse to null', zeroYears.years === 0)
|
||||||
|
|
||||||
|
const derivedBand = toBankRowView({
|
||||||
|
id: 'app:6', record_id: '6', bank_source: 'silver_medalist',
|
||||||
|
name: 'Derived', ai_score: 70,
|
||||||
|
})
|
||||||
|
ok('a score without a band derives one', derivedBand.recommendation === 'Potential Match')
|
||||||
|
|
||||||
|
/* --- expiry wording ------------------------------------------------------ */
|
||||||
|
|
||||||
|
const now = new Date('2026-09-03T00:00:00Z')
|
||||||
|
ok('no expiry has no label', expiryLabel(null, now) === null)
|
||||||
|
ok('months are counted forward', expiryLabel(new Date('2027-04-01T00:00:00Z'), now) === 'expires in 7 months')
|
||||||
|
ok('one month is singular', expiryLabel(new Date('2026-10-03T00:00:00Z'), now) === 'expires in 1 month')
|
||||||
|
ok(
|
||||||
|
'a past window reads as expired, not a negative count',
|
||||||
|
expiryLabel(new Date('2026-01-01T00:00:00Z'), now) === 'expired',
|
||||||
|
)
|
||||||
|
|
||||||
|
rmSync(outDir, { recursive: true, force: true })
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
console.log(`\n${failed} check(s) failed`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log('\nAll CV Bank mapper checks passed')
|
||||||
|
|
@ -24,8 +24,8 @@
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700&display=swap" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700&display=swap" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||||
<script type="module" crossorigin src="/assets/index-CLFuERsT.js"></script>
|
<script type="module" crossorigin src="/assets/index-CcHnHosB.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CuQsXTK_.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DauwX3K5.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,9 @@ const EMAIL_ROWS = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Matches DEFAULT_FORM_SHEET in Inbox.jsx, so the sheet picker resolves. */
|
||||||
|
const FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026'
|
||||||
|
|
||||||
const FORM_ROWS = [
|
const FORM_ROWS = [
|
||||||
{
|
{
|
||||||
id: '33333333-3333-3333-3333-333333333333',
|
id: '33333333-3333-3333-3333-333333333333',
|
||||||
|
|
@ -123,14 +126,23 @@ const formGate = gate({ data: FORM_ROWS, total: FORM_ROWS.length, status_code: 2
|
||||||
|
|
||||||
const COUNTS = { all: 3, unread: 1, processed: 0, rejected: 0, duplicates: 0 }
|
const COUNTS = { all: 3, unread: 1, processed: 0, rejected: 0, duplicates: 0 }
|
||||||
|
|
||||||
|
/** Every URL the app asked for, so a test can assert on query params. */
|
||||||
|
const REQUESTS = []
|
||||||
|
const requestsMatching = (fragment) => REQUESTS.filter((u) => u.includes(fragment))
|
||||||
|
|
||||||
globalThis.fetch = async (input) => {
|
globalThis.fetch = async (input) => {
|
||||||
const url = String(input?.url ?? input)
|
const url = String(input?.url ?? input)
|
||||||
|
REQUESTS.push(url)
|
||||||
let body
|
let body
|
||||||
if (url.includes('/inbox/all-applications/count')) body = { total: EMAIL_ROWS.length, status_code: 200 }
|
if (url.includes('/inbox/all-applications/count')) body = { total: EMAIL_ROWS.length, status_code: 200 }
|
||||||
else if (url.includes('/inbox/all-applications')) body = await emailGate.take()
|
else if (url.includes('/inbox/all-applications')) body = await emailGate.take()
|
||||||
else if (url.includes('/inbox/counts')) body = { data: COUNTS, status_code: 200 }
|
else if (url.includes('/inbox/counts')) body = { data: COUNTS, status_code: 200 }
|
||||||
|
// '/counts' BEFORE '/count': the shorter path is a substring of the longer
|
||||||
|
// one, so testing it first swallowed every counts request and handed the tab
|
||||||
|
// badges a payload of the wrong shape.
|
||||||
|
else if (url.includes('/sheet/form-data/counts')) body = { data: { all: FORM_ROWS.length }, status_code: 200 }
|
||||||
else if (url.includes('/sheet/form-data/count')) body = { total: FORM_ROWS.length, status_code: 200 }
|
else if (url.includes('/sheet/form-data/count')) body = { total: FORM_ROWS.length, status_code: 200 }
|
||||||
else if (url.includes('/sheet/form-data/counts')) body = { data: { all: 1 }, status_code: 200 }
|
else if (url.includes('/sheet/form-data/sheets')) body = { data: { sheets: [FORM_SHEET] }, status_code: 200 }
|
||||||
else if (url.includes('/sheet/form-data/fetch')) body = await formGate.take()
|
else if (url.includes('/sheet/form-data/fetch')) body = await formGate.take()
|
||||||
else body = { data: [], status_code: 200 }
|
else body = { data: [], status_code: 200 }
|
||||||
return {
|
return {
|
||||||
|
|
@ -297,6 +309,97 @@ try {
|
||||||
`calls before=${firstVisitEmailCalls} after=${emailGate.calls}`,
|
`calls before=${firstVisitEmailCalls} after=${emailGate.calls}`,
|
||||||
)
|
)
|
||||||
await revisit.unmount()
|
await revisit.unmount()
|
||||||
|
|
||||||
|
// ---- Sheet Forms link filters -------------------------------------------
|
||||||
|
// A Google Form pile has two questions worth asking of it in bulk: who gave
|
||||||
|
// us a LinkedIn, and who gave us a CV. Both answers were already in the
|
||||||
|
// database and neither was reachable from the screen.
|
||||||
|
const third = dom.window.document.createElement('div')
|
||||||
|
dom.window.document.body.appendChild(third)
|
||||||
|
const forms = await mod.mountRoute('/inbox', third)
|
||||||
|
|
||||||
|
check(
|
||||||
|
'no filter panel on the Email channel',
|
||||||
|
!forms.findByText('button', 'Filters'),
|
||||||
|
'the link fields are sheet-only; email rows carry different columns',
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUESTS.length = 0
|
||||||
|
await forms.click(forms.findByText('.pill-tab', 'Sheet Forms'))
|
||||||
|
await forms.settle(60)
|
||||||
|
|
||||||
|
// Captured BEFORE any filter is chosen. This is what makes the "clearing
|
||||||
|
// removes the params" claim further down mean something: an inactive filter
|
||||||
|
// has to be absent from the URL, not present and empty.
|
||||||
|
const unfiltered = requestsMatching('/sheet/form-data/fetch')
|
||||||
|
check(
|
||||||
|
'an unset filter is absent from the request, not sent empty',
|
||||||
|
unfiltered.length > 0
|
||||||
|
&& unfiltered.every((u) => !u.includes('has_linkedin') && !u.includes('has_resume')),
|
||||||
|
unfiltered.slice(-1)[0] || 'no list request went out at all',
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggle = forms.findByText('button', 'Filters')
|
||||||
|
check('the Sheet Forms channel offers a filter toggle', Boolean(toggle))
|
||||||
|
|
||||||
|
await forms.click(toggle)
|
||||||
|
const linkedinSelect = forms.find('#inbox-f-linkedin')
|
||||||
|
const resumeSelect = forms.find('#inbox-f-resume')
|
||||||
|
check(
|
||||||
|
'opening it reveals both link filters',
|
||||||
|
Boolean(linkedinSelect) && Boolean(resumeSelect),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'the LinkedIn filter admits what it can actually prove',
|
||||||
|
forms.text().includes('not a verified profile'),
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUESTS.length = 0
|
||||||
|
await forms.selectOption(linkedinSelect, 'yes')
|
||||||
|
await forms.settle(80)
|
||||||
|
|
||||||
|
const listHits = requestsMatching('/sheet/form-data/fetch')
|
||||||
|
const countHits = requestsMatching('/sheet/form-data/counts')
|
||||||
|
check(
|
||||||
|
'choosing a filter sends it to the list endpoint',
|
||||||
|
listHits.some((u) => u.includes('has_linkedin=true')),
|
||||||
|
listHits[listHits.length - 1] || 'no list request went out',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'THE DESYNC GUARD: the tab badges are recounted with the same filter',
|
||||||
|
countHits.some((u) => u.includes('has_linkedin=true')),
|
||||||
|
countHits[countHits.length - 1] || 'no counts request went out',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 'no' has to survive as a real filter. A checkbox would collapse it into
|
||||||
|
// "unset", and chasing the rows MISSING a link is half the point.
|
||||||
|
REQUESTS.length = 0
|
||||||
|
await forms.selectOption(resumeSelect, 'no')
|
||||||
|
await forms.settle(80)
|
||||||
|
check(
|
||||||
|
'a negative filter reaches the wire as false, not as omitted',
|
||||||
|
requestsMatching('/sheet/form-data/fetch').some((u) => u.includes('has_resume=false')),
|
||||||
|
requestsMatching('/sheet/form-data/fetch').slice(-1)[0] || 'no request',
|
||||||
|
)
|
||||||
|
|
||||||
|
check(
|
||||||
|
'the toggle reports how many filters are hiding under it',
|
||||||
|
(forms.findByText('button', 'Filters')?.textContent || '').includes('(2)'),
|
||||||
|
`toggle reads: ${forms.findByText('button', 'Filters')?.textContent?.trim()}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await forms.click(forms.findByText('button', 'Clear'))
|
||||||
|
await forms.settle(80)
|
||||||
|
const clearedToggle = forms.findByText('button', 'Filters')
|
||||||
|
check(
|
||||||
|
'clearing resets both selects and the toggle count',
|
||||||
|
!(clearedToggle?.textContent || '').includes('(')
|
||||||
|
&& forms.find('#inbox-f-linkedin')?.value === ''
|
||||||
|
&& forms.find('#inbox-f-resume')?.value === '',
|
||||||
|
`toggle reads: ${clearedToggle?.textContent?.trim()}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await forms.unmount()
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(outDir, { recursive: true, force: true })
|
rmSync(outDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const PASSWORD = process.env.ATS_TEST_PASSWORD || 'Test12345!'
|
||||||
|
|
||||||
// Keep in sync with src/app/routes.js (paths only — titles don't matter here).
|
// Keep in sync with src/app/routes.js (paths only — titles don't matter here).
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
'dashboard', 'inbox', 'matching', 'jobs', 'candidates', 'talentpool', 'pipeline',
|
'dashboard', 'inbox', 'matching', 'jobs', 'candidates', 'cvbank', 'pipeline',
|
||||||
'progress', 'import', 'jobboard', 'recruiterhub', 'talent', 'tasks', 'aiassistant',
|
'progress', 'import', 'jobboard', 'recruiterhub', 'talent', 'tasks', 'aiassistant',
|
||||||
'interviews', 'requisitions', 'assessments', 'offers', 'managers', 'calendar',
|
'interviews', 'requisitions', 'assessments', 'offers', 'managers', 'calendar',
|
||||||
'reports', 'analytics', 'aistudio', 'notifications', 'rbac', 'settings', 'help',
|
'reports', 'analytics', 'aistudio', 'notifications', 'rbac', 'settings', 'help',
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@
|
||||||
"test:token": "node token.test.mjs",
|
"test:token": "node token.test.mjs",
|
||||||
"test:theme": "node theme.test.mjs",
|
"test:theme": "node theme.test.mjs",
|
||||||
"test:inbox": "node inbox-loading.test.mjs",
|
"test:inbox": "node inbox-loading.test.mjs",
|
||||||
|
"test:candidates": "node candidates-table.test.mjs",
|
||||||
|
"test:cvbank": "node cvbank.test.mjs",
|
||||||
"test:mobile": "node mobile.test.mjs",
|
"test:mobile": "node mobile.test.mjs",
|
||||||
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node inbox-loading.test.mjs"
|
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const SCREENS = {
|
||||||
matching: lazy(() => import('./screens/Matching')),
|
matching: lazy(() => import('./screens/Matching')),
|
||||||
jobs: lazy(() => import('./screens/Jobs')),
|
jobs: lazy(() => import('./screens/Jobs')),
|
||||||
candidates: lazy(() => import('./screens/Candidates')),
|
candidates: lazy(() => import('./screens/Candidates')),
|
||||||
talentpool: lazy(() => import('./screens/TalentPool')),
|
cvbank: lazy(() => import('./screens/CvBank')),
|
||||||
pipeline: lazy(() => import('./screens/Pipeline')),
|
pipeline: lazy(() => import('./screens/Pipeline')),
|
||||||
progress: lazy(() => import('./screens/Progress')),
|
progress: lazy(() => import('./screens/Progress')),
|
||||||
import: lazy(() => import('./screens/CvImport')),
|
import: lazy(() => import('./screens/CvImport')),
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import Inbox from '../screens/Inbox'
|
||||||
import Matching from '../screens/Matching'
|
import Matching from '../screens/Matching'
|
||||||
import Jobs from '../screens/Jobs'
|
import Jobs from '../screens/Jobs'
|
||||||
import Candidates from '../screens/Candidates'
|
import Candidates from '../screens/Candidates'
|
||||||
import TalentPool from '../screens/TalentPool'
|
import CvBank from '../screens/CvBank'
|
||||||
import Pipeline from '../screens/Pipeline'
|
import Pipeline from '../screens/Pipeline'
|
||||||
import Progress from '../screens/Progress'
|
import Progress from '../screens/Progress'
|
||||||
import CvImport from '../screens/CvImport'
|
import CvImport from '../screens/CvImport'
|
||||||
|
|
@ -53,7 +53,7 @@ import Help from '../screens/Help'
|
||||||
|
|
||||||
const SCREENS = {
|
const SCREENS = {
|
||||||
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
||||||
talentpool: TalentPool, pipeline: Pipeline, progress: Progress, import: CvImport, jobboard: JobBoard,
|
cvbank: CvBank, pipeline: Pipeline, progress: Progress, import: CvImport, jobboard: JobBoard,
|
||||||
recruiterhub: RecruiterHub, talent: Talent, tasks: Tasks, aiassistant: AiAssistant,
|
recruiterhub: RecruiterHub, talent: Talent, tasks: Tasks, aiassistant: AiAssistant,
|
||||||
interviews: Interviews, requisitions: Requisitions, assessments: Assessments, offers: Offers,
|
interviews: Interviews, requisitions: Requisitions, assessments: Assessments, offers: Offers,
|
||||||
managers: Managers, calendar: Calendar, reports: Reports, analytics: Analytics,
|
managers: Managers, calendar: Calendar, reports: Reports, analytics: Analytics,
|
||||||
|
|
@ -95,10 +95,33 @@ export async function mountRoute(path, container) {
|
||||||
await act(async () => { await new Promise((r) => setTimeout(r, ms)) })
|
await act(async () => { await new Promise((r) => setTimeout(r, ms)) })
|
||||||
}
|
}
|
||||||
await settle()
|
await settle()
|
||||||
|
|
||||||
|
// Interaction helpers live here for the same reason `settle` does: act() has
|
||||||
|
// to be the bundle's React instance, not a second copy imported by the test.
|
||||||
|
const click = async (el) => {
|
||||||
|
if (!el) throw new Error('click: element not found')
|
||||||
|
await act(async () => { el.click() })
|
||||||
|
await settle()
|
||||||
|
}
|
||||||
|
const selectOption = async (el, value) => {
|
||||||
|
if (!el) throw new Error('selectOption: element not found')
|
||||||
|
const Ev = el.ownerDocument.defaultView.Event
|
||||||
|
await act(async () => {
|
||||||
|
el.value = value
|
||||||
|
el.dispatchEvent(new Ev('change', { bubbles: true }))
|
||||||
|
})
|
||||||
|
await settle()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settle,
|
settle,
|
||||||
|
click,
|
||||||
|
selectOption,
|
||||||
html: () => container.innerHTML,
|
html: () => container.innerHTML,
|
||||||
text: () => container.textContent || '',
|
text: () => container.textContent || '',
|
||||||
|
find: (selector) => container.querySelector(selector),
|
||||||
|
findByText: (selector, text) => [...container.querySelectorAll(selector)]
|
||||||
|
.find((el) => (el.textContent || '').includes(text)) || null,
|
||||||
unmount: async () => { await act(async () => { root.unmount() }) },
|
unmount: async () => { await act(async () => { root.unmount() }) },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,47 @@ export function uploadToCvBank(file) {
|
||||||
return request('/candidate/cv-bank/upload', { method: 'POST', body: form })
|
return request('/candidate/cv-bank/upload', { method: 'POST', body: form })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The stored-CV bank, newest first — GET /candidate/cv-bank/fetch. */
|
/**
|
||||||
export function listCvBank({ top = 100, skip = 0 } = {}) {
|
* The CV Bank — GET /candidate/cv-bank/fetch. Two populations in one list:
|
||||||
return request('/candidate/cv-bank/fetch', { params: { top, skip } })
|
* speculative uploads with no job, and rejected applicants who scored well.
|
||||||
|
*
|
||||||
|
* `jobPostId` does NOT filter. It attaches rank_score (deterministic keyword
|
||||||
|
* overlap against that job) and sorts by it — the "a role just opened, who do
|
||||||
|
* we already have" view.
|
||||||
|
*/
|
||||||
|
export function listCvBank({
|
||||||
|
top = 100, skip = 0, source, search, skills, minYears, band, jobPostId,
|
||||||
|
} = {}) {
|
||||||
|
return request('/candidate/cv-bank/fetch', {
|
||||||
|
params: {
|
||||||
|
top, skip, source, search, skills,
|
||||||
|
min_years: minYears,
|
||||||
|
band,
|
||||||
|
job_post_id: jobPostId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banked CVs worth reviewing for one job, best first. Needs candidates.view.
|
||||||
|
* Same rows as listCvBank with a job context, already cut at the threshold.
|
||||||
|
*/
|
||||||
|
export function listCvBankSuggestions({ jobPostId, top = 20, minRank } = {}) {
|
||||||
|
return request('/candidate/cv-bank/suggestions', {
|
||||||
|
params: { job_post_id: jobPostId, top, min_rank: minRank },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the real ATS score on CVs already in the bank. Needs candidates.create.
|
||||||
|
* This is the paid step — rank_score on the list is free keyword overlap and
|
||||||
|
* is not a score. Results land in candidates/ats_results like any other CV.
|
||||||
|
*/
|
||||||
|
export function scoreCvBank(jobId, ids) {
|
||||||
|
return request('/candidate/cv-bank/score', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { job_id: jobId, ids },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Permanently remove a stored CV (file included). Needs candidates.delete. */
|
/** Permanently remove a stored CV (file included). Needs candidates.delete. */
|
||||||
|
|
@ -246,7 +284,80 @@ export function toApplicationListView(row) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const BANK_SOURCE_LABELS = {
|
||||||
|
speculative: 'Speculative',
|
||||||
|
silver_medalist: 'Silver medalist',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /candidate/cv-bank/fetch row -> the CV Bank table.
|
||||||
|
*
|
||||||
|
* Two numbers that must never be confused: `aiScore` is a real paid ATS score
|
||||||
|
* and only exists once someone ran one; `rankScore` is free keyword overlap
|
||||||
|
* against whichever job is selected. The screen renders them differently on
|
||||||
|
* purpose.
|
||||||
|
*/
|
||||||
|
export function toBankRowView(row) {
|
||||||
|
const score = row.ai_score == null || row.ai_score === '' ? null : Number(row.ai_score)
|
||||||
|
const aiScore = Number.isFinite(score) ? score : null
|
||||||
|
const rank = row.rank_score == null || row.rank_score === '' ? null : Number(row.rank_score)
|
||||||
|
const years = row.years_experience == null || row.years_experience === ''
|
||||||
|
? null
|
||||||
|
: Number(row.years_experience)
|
||||||
|
const expires = row.bank_expires_at ? new Date(row.bank_expires_at) : null
|
||||||
|
return {
|
||||||
|
id: String(row.id || ''),
|
||||||
|
recordId: row.record_id != null ? String(row.record_id) : null,
|
||||||
|
source: row.bank_source || 'speculative',
|
||||||
|
sourceLabel: BANK_SOURCE_LABELS[row.bank_source] || 'Speculative',
|
||||||
|
// A silver medalist is read live from their application, so removing or
|
||||||
|
// re-scoring them is not the bank's call to make.
|
||||||
|
isStoredCv: row.bank_source !== 'silver_medalist',
|
||||||
|
name: row.name || row.email || 'Unknown',
|
||||||
|
email: row.email ?? null,
|
||||||
|
phone: row.phone ?? null,
|
||||||
|
fileName: row.file_name ?? null,
|
||||||
|
filePath: row.file_path ?? null,
|
||||||
|
linkedinUrl: row.linkedin_url ?? null,
|
||||||
|
company: row.current_company ?? null,
|
||||||
|
title: row.current_position ?? null,
|
||||||
|
education: row.education ?? null,
|
||||||
|
skills: Array.isArray(row.skills) ? row.skills : [],
|
||||||
|
years: Number.isFinite(years) ? years : null,
|
||||||
|
aiScore,
|
||||||
|
recommendation: bandOf(aiScore, row.recommendation || null),
|
||||||
|
rankScore: Number.isFinite(rank) ? rank : null,
|
||||||
|
lastJobTitle: row.last_job_title ?? null,
|
||||||
|
bankReason: row.bank_reason ?? null,
|
||||||
|
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires : null,
|
||||||
|
userId: row.user_id ?? null,
|
||||||
|
added: row.created_at ? new Date(row.created_at) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "expires in 7 months", or null when nothing is set. Past expiry reads as
|
||||||
|
* "expired" rather than a negative count — the row still exists and someone
|
||||||
|
* has to decide what to do about it.
|
||||||
|
*/
|
||||||
|
export function expiryLabel(expiresAt, now = new Date()) {
|
||||||
|
if (!expiresAt) return null
|
||||||
|
const months = Math.round((expiresAt - now) / (1000 * 60 * 60 * 24 * 30))
|
||||||
|
if (months <= 0) return 'expired'
|
||||||
|
if (months === 1) return 'expires in 1 month'
|
||||||
|
return `expires in ${months} months`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate profiles — the `inbox -> users -> roles` join, restricted server-side
|
||||||
|
* to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).
|
||||||
|
*
|
||||||
|
* Permissioned with require_permission(CANDIDATES_VIEW), so a caller without the
|
||||||
|
* tag gets a 403.
|
||||||
|
*
|
||||||
|
* `search` is an ilike over users.name / users.email only — it does NOT reach
|
||||||
|
* the résumé text or the suggested job titles.
|
||||||
|
*/
|
||||||
export function list({ search, limit, offset, assignedJobPostId } = {}) {
|
export function list({ search, limit, offset, assignedJobPostId } = {}) {
|
||||||
return request('/candidate/fetch', {
|
return request('/candidate/fetch', {
|
||||||
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId },
|
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId },
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,20 @@ export function listFormDataSheets() {
|
||||||
*
|
*
|
||||||
* `offset` / `limit` map 1:1 to the backend Query params (not skip/top).
|
* `offset` / `limit` map 1:1 to the backend Query params (not skip/top).
|
||||||
* Optional `processing_state` / `is_duplicate` power the Sheet Forms tabs.
|
* Optional `processing_state` / `is_duplicate` power the Sheet Forms tabs.
|
||||||
|
*
|
||||||
|
* `has_linkedin` / `has_resume` are tri-valued on the wire, the same convention
|
||||||
|
* `is_duplicate` uses: omit for no filter, false to find the rows MISSING the
|
||||||
|
* link. buildUrl drops undefined but keeps false, so `undefined` sends no param.
|
||||||
*/
|
*/
|
||||||
export function listFormData({
|
export function listFormData({
|
||||||
sheet, search, offset = 0, limit, processing_state, is_duplicate,
|
sheet, search, offset = 0, limit, processing_state, is_duplicate,
|
||||||
|
hasLinkedin, hasResume,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return request('/sheet/form-data/fetch', {
|
return request('/sheet/form-data/fetch', {
|
||||||
params: { sheet, search, offset, limit, processing_state, is_duplicate },
|
params: {
|
||||||
|
sheet, search, offset, limit, processing_state, is_duplicate,
|
||||||
|
has_linkedin: hasLinkedin, has_resume: hasResume,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,9 +39,18 @@ export function countFormData({ sheet } = {}) {
|
||||||
return request('/sheet/form-data/count', { params: { sheet } })
|
return request('/sheet/form-data/count', { params: { sheet } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tab badge counts for one sheet (or all sheets when sheet omitted). */
|
/**
|
||||||
export function fetchFormCounts({ sheet } = {}) {
|
* Tab badge counts for one sheet (or all sheets when sheet omitted).
|
||||||
return request('/sheet/form-data/counts', { params: { sheet } })
|
*
|
||||||
|
* Takes the same narrowing filters as the list, because a badge reading 612
|
||||||
|
* above twelve visible rows reads as a bug. It deliberately does NOT take
|
||||||
|
* processing_state or is_duplicate: those two ARE the tabs, and passing them
|
||||||
|
* would make every badge report the tab the user is already on.
|
||||||
|
*/
|
||||||
|
export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume } = {}) {
|
||||||
|
return request('/sheet/form-data/counts', {
|
||||||
|
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One form_data row by UUID. */
|
/** One form_data row by UUID. */
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,12 @@ export const ROUTES = [
|
||||||
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' },
|
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' },
|
||||||
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
|
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
|
||||||
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
|
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
|
||||||
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },
|
// Replaces Talent Pool. That screen browsed candidate accounts over a seed
|
||||||
|
// overlay of invented skills and companies; /candidates already does the real
|
||||||
|
// version of that. This one holds the people we have no job for yet.
|
||||||
|
// No `badge`: the matching badge counts the unassigned queue and reusing the
|
||||||
|
// key here would make the same rows read as two separate counts.
|
||||||
|
{ path: 'cvbank', title: 'CV Bank', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },
|
||||||
{ path: 'pipeline', title: 'Pipeline', icon: 'pipeline', group: 'Workspace', permission: 'pipeline.view' },
|
{ path: 'pipeline', title: 'Pipeline', icon: 'pipeline', group: 'Workspace', permission: 'pipeline.view' },
|
||||||
{ path: 'progress', title: 'Progress', icon: 'trending-up', group: 'Workspace', permission: 'jobs.view' },
|
{ path: 'progress', title: 'Progress', icon: 'trending-up', group: 'Workspace', permission: 'jobs.view' },
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export function makeCan(permissions) {
|
||||||
|
|
||||||
export const HIRING_MANAGER_ROLE = 'hiring_manager'
|
export const HIRING_MANAGER_ROLE = 'hiring_manager'
|
||||||
|
|
||||||
/** Sidebar paths a manager-type role may see. Talent Pool / Matching / Import
|
/** Sidebar paths a manager-type role may see. CV Bank / Matching / Import
|
||||||
also sit on candidates.view/create, so they are excluded here. */
|
also sit on candidates.view/create, so they are excluded here. */
|
||||||
export const HIRING_MANAGER_NAV = new Set([
|
export const HIRING_MANAGER_NAV = new Set([
|
||||||
'candidates', 'requisitions', 'interviews', 'calendar',
|
'candidates', 'requisitions', 'interviews', 'calendar',
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ export const qk = {
|
||||||
},
|
},
|
||||||
cvBank: {
|
cvBank: {
|
||||||
all: () => ['cvBank'],
|
all: () => ['cvBank'],
|
||||||
list: () => ['cvBank', 'list'],
|
list: (p = {}) => ['cvBank', 'list', p],
|
||||||
|
suggestions: (jobId) => ['cvBank', 'suggestions', jobId],
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
all: () => ['notifications'],
|
all: () => ['notifications'],
|
||||||
|
|
|
||||||
|
|
@ -788,9 +788,7 @@ function fmtStamp(value) {
|
||||||
* (backend/job/candidate/views.py:782-792).
|
* (backend/job/candidate/views.py:782-792).
|
||||||
*
|
*
|
||||||
* The prop is the last fallback, for callers whose rows already carry a score
|
* The prop is the last fallback, for callers whose rows already carry a score
|
||||||
* (Talent Pool cards, the scored leaderboard).
|
* (the scored leaderboard).
|
||||||
*
|
|
||||||
* Exported so TalentPool's profile modal can open the same ATS breakdown.
|
|
||||||
*/
|
*/
|
||||||
export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
|
export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
|
||||||
const userId = c.userId ?? null
|
const userId = c.userId ?? null
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,639 @@
|
||||||
|
/* ============================================================
|
||||||
|
CV Bank — people we already have, for jobs we do not have yet.
|
||||||
|
|
||||||
|
Two populations, one table (GET /candidate/cv-bank/fetch):
|
||||||
|
|
||||||
|
Speculative a CV uploaded with no job attached. Skills, title,
|
||||||
|
company and years are extracted at upload, which is what
|
||||||
|
makes the row searchable at all.
|
||||||
|
Silver medalist someone who applied, scored well, and did not get the
|
||||||
|
job. Read live from their application rather than copied
|
||||||
|
here, so there is one source of truth and nothing to sync.
|
||||||
|
|
||||||
|
Two very different numbers live on this screen and must not be confused:
|
||||||
|
|
||||||
|
Match free, deterministic keyword overlap against the job picked in
|
||||||
|
"Rank against job". It orders the pile. It is not an assessment.
|
||||||
|
ATS a real paid score, and only present once someone ran one. The
|
||||||
|
"Score against job" action is what runs it, deliberately per-row.
|
||||||
|
|
||||||
|
That split is the whole design: ranking the bank costs nothing and happens
|
||||||
|
automatically when a job opens, so scoring can stay explicit and cheap.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||||
|
import PageHeader from '../ui/PageHeader'
|
||||||
|
import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
|
||||||
|
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
||||||
|
import { useToast } from '../ui/Toast'
|
||||||
|
import { qk } from '../lib/queryKeys'
|
||||||
|
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||||
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
|
import * as candidatesApi from '../api/candidates'
|
||||||
|
import * as s3Api from '../api/s3'
|
||||||
|
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
|
const SOURCE_FILTERS = ['speculative', 'silver_medalist']
|
||||||
|
const SOURCE_LABELS = candidatesApi.BANK_SOURCE_LABELS
|
||||||
|
const BAND_FILTERS = ['Strong Match', 'Potential Match', 'Weak Match', 'Unscored']
|
||||||
|
const YEARS_FILTERS = ['1', '2', '3', '5', '8', '10']
|
||||||
|
const BAND_BADGE = {
|
||||||
|
'Strong Match': 'b-green',
|
||||||
|
'Potential Match': 'b-amber',
|
||||||
|
'Weak Match': 'b-gray',
|
||||||
|
}
|
||||||
|
const SOURCE_BADGE = {
|
||||||
|
speculative: 'b-indigo',
|
||||||
|
silver_medalist: 'b-teal',
|
||||||
|
}
|
||||||
|
/* Chips past this are collapsed into "+N" — a CV with 25 skills would
|
||||||
|
otherwise make one row taller than the rest of the page. */
|
||||||
|
const SKILL_CHIPS = 4
|
||||||
|
|
||||||
|
const EMPTY_FILTERS = { source: '', band: '', years: '' }
|
||||||
|
|
||||||
|
async function fetchBank({ limit, offset, search, filters, jobPostId }) {
|
||||||
|
const res = await candidatesApi.listCvBank({
|
||||||
|
top: limit,
|
||||||
|
skip: offset,
|
||||||
|
search: search || undefined,
|
||||||
|
source: filters.source || undefined,
|
||||||
|
band: filters.band || undefined,
|
||||||
|
minYears: filters.years ? Number(filters.years) : undefined,
|
||||||
|
jobPostId: jobPostId || undefined,
|
||||||
|
})
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return {
|
||||||
|
rows: rows.map(candidatesApi.toBankRowView),
|
||||||
|
total: Number(res?.total ?? rows.length) || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJobs() {
|
||||||
|
const res = await candidatesApi.listJobs()
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return rows.map((row) => ({ id: String(row.id), title: row.title }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The free deterministic rank. Drawn as a plain bar, never as a ScoreChip —
|
||||||
|
a recruiter must not read it in the same visual language as a real ATS score. */
|
||||||
|
function MatchCell({ rank, hasJob }) {
|
||||||
|
if (!hasJob) return <span className="text-muted text-sm">Pick a job</span>
|
||||||
|
if (rank == null) return <span className="text-muted">—</span>
|
||||||
|
return (
|
||||||
|
<div style={{ minWidth: 92 }}>
|
||||||
|
<div className="fw-600 text-sm">{rank}<span className="text-muted" style={{ fontWeight: 400 }}>/100</span></div>
|
||||||
|
<div
|
||||||
|
style={{ height: 4, borderRadius: 2, background: 'var(--bg-sunken)', marginTop: 4 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div style={{ width: `${Math.min(100, rank)}%`, height: '100%', borderRadius: 2, background: 'var(--primary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AtsCell({ score, recommendation }) {
|
||||||
|
if (score == null) return <span className="text-muted text-sm">Not scored</span>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ScoreChip score={score} />
|
||||||
|
{recommendation && (
|
||||||
|
<div className="cell-sub">
|
||||||
|
<Badge className={BAND_BADGE[recommendation] || 'b-gray'}>{recommendation}</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CvBank() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [params, setParams] = useSearchParams()
|
||||||
|
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [filters, setFilters] = useState(EMPTY_FILTERS)
|
||||||
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
|
const [skip, setSkip] = useState(0)
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||||
|
const [preview, setPreview] = useState(null) // { name, url } — object URL we own
|
||||||
|
const [scoreFor, setScoreFor] = useState(null)
|
||||||
|
const [assignFor, setAssignFor] = useState(null)
|
||||||
|
|
||||||
|
/* The rank job lives in the URL so the notification fired on job creation
|
||||||
|
("/cvbank?job=<id>") lands on the ranked view rather than a generic list. */
|
||||||
|
const jobPostId = params.get('job') || ''
|
||||||
|
const setJobPostId = (next) => {
|
||||||
|
const p = new URLSearchParams(params)
|
||||||
|
if (next) p.set('job', next)
|
||||||
|
else p.delete('job')
|
||||||
|
setParams(p, { replace: true })
|
||||||
|
setSkip(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [q])
|
||||||
|
useEffect(() => { setSkip(0) }, [search])
|
||||||
|
|
||||||
|
const bankQuery = useQuery({
|
||||||
|
queryKey: qk.cvBank.list({ limit: pageSize, offset: skip, search, ...filters, jobPostId }),
|
||||||
|
queryFn: () => fetchBank({ limit: pageSize, offset: skip, search, filters, jobPostId }),
|
||||||
|
})
|
||||||
|
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
|
||||||
|
|
||||||
|
const rows = useMemo(() => bankQuery.data?.rows ?? [], [bankQuery.data])
|
||||||
|
const total = bankQuery.data?.total ?? 0
|
||||||
|
const jobs = jobsQuery.data ?? []
|
||||||
|
const selectedJob = jobs.find((j) => j.id === jobPostId) || null
|
||||||
|
|
||||||
|
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
const from = total ? skip + 1 : 0
|
||||||
|
const to = total ? skip + rows.length : 0
|
||||||
|
const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (total <= 0 || skip < total) return
|
||||||
|
setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
|
||||||
|
}, [total, pageSize, skip])
|
||||||
|
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{ key: 'name', label: 'Candidate', sortable: true },
|
||||||
|
{ key: 'source', label: 'Source', sortable: true },
|
||||||
|
{ key: 'title', label: 'Role', sortable: true },
|
||||||
|
{ key: 'years', label: 'Years', sortable: true },
|
||||||
|
{ key: 'skills', label: 'Skills', sortable: false },
|
||||||
|
{ key: 'rankScore', label: 'Match', sortable: true },
|
||||||
|
{ key: 'aiScore', label: 'ATS', sortable: true },
|
||||||
|
{ key: 'added', label: 'Added', sortable: true },
|
||||||
|
{ key: 'actions', label: '', sortable: false },
|
||||||
|
], [])
|
||||||
|
|
||||||
|
// The server already sorted and paged; pageSize here is just "show them all".
|
||||||
|
const t = useDataTable({ columns, rows, pageSize: Math.max(rows.length, 1) })
|
||||||
|
|
||||||
|
const setFilter = (k, v) => {
|
||||||
|
setFilters((f) => ({ ...f, [k]: v }))
|
||||||
|
setSkip(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removing = useMutation({
|
||||||
|
mutationFn: (id) => candidatesApi.deleteCvBankCv(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
||||||
|
toast('CV removed from the bank', 'success')
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const scoring = useMutation({
|
||||||
|
mutationFn: ({ jobId, ids }) => candidatesApi.scoreCvBank(jobId, ids),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const row = Array.isArray(res?.data) ? res.data[0] : null
|
||||||
|
if (row?.status === 'completed') {
|
||||||
|
toast(`Scored ${row.match_score}/100 — the result is on Candidates now`, 'success')
|
||||||
|
} else {
|
||||||
|
toast(`Could not score the CV${row?.error_code ? ` — ${row.error_code}` : ''}`, 'warning')
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||||
|
setScoreFor(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Scoring failed'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const assigning = useMutation({
|
||||||
|
mutationFn: ({ id, jobId }) => candidatesApi.assignMatchingJob(id, jobId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||||
|
toast('CV assigned — it is in the pipeline now', 'success')
|
||||||
|
setAssignFor(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the job'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function view(row) {
|
||||||
|
if (!row.isStoredCv) {
|
||||||
|
if (row.userId) navigate(`/candidate/${row.userId}`)
|
||||||
|
else toast('This applicant has no profile to open', 'info')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tab = s3Api.canOpen(row.filePath) ? window.open('about:blank', '_blank') : null
|
||||||
|
try {
|
||||||
|
if (s3Api.canOpen(row.filePath)) {
|
||||||
|
await s3Api.openPdf(row.filePath, { tab })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = await candidatesApi.viewCvBankCv(row.recordId)
|
||||||
|
if (!url) {
|
||||||
|
toast('The CV file could not be found', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPreview({ name: row.fileName || row.name || 'CV', url })
|
||||||
|
} catch (err) {
|
||||||
|
if (tab && !tab.closed) tab.close()
|
||||||
|
toast(friendlyAuthError(err, 'Could not open the CV'), 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreview() {
|
||||||
|
if (preview) URL.revokeObjectURL(preview.url)
|
||||||
|
setPreview(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(row) {
|
||||||
|
try {
|
||||||
|
await candidatesApi.downloadCvBankCv(row.recordId)
|
||||||
|
} catch (err) {
|
||||||
|
toast(friendlyAuthError(err, 'Could not download the CV'), 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportRows() {
|
||||||
|
if (!rows.length) {
|
||||||
|
toast('Nothing to export — current filters match no CVs', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await exportStyledXlsx({
|
||||||
|
filename: `cv-bank-${new Date().toISOString().slice(0, 10)}`,
|
||||||
|
title: 'CV Bank',
|
||||||
|
subtitle: `${rows.length} CV${rows.length === 1 ? '' : 's'}${selectedJob ? ` · ranked against ${selectedJob.title}` : ''} · exported ${new Date().toLocaleDateString()}`,
|
||||||
|
columns: [
|
||||||
|
{ header: 'Name', key: 'name', width: 26 },
|
||||||
|
{ header: 'Email', key: 'email', width: 30 },
|
||||||
|
{ header: 'Source', key: 'source', width: 16 },
|
||||||
|
{ header: 'Title', key: 'title', width: 24 },
|
||||||
|
{ header: 'Company', key: 'company', width: 24 },
|
||||||
|
{ header: 'Years', key: 'years', width: 8 },
|
||||||
|
{ header: 'Skills', key: 'skills', width: 42 },
|
||||||
|
{ header: 'Match', key: 'match', width: 10 },
|
||||||
|
{ header: 'ATS', key: 'ats', width: 10 },
|
||||||
|
{ header: 'Added', key: 'added', width: 12 },
|
||||||
|
],
|
||||||
|
// Every column is extracted from the CV or read off a real application.
|
||||||
|
// Talent Pool exported invented skills and companies; this does not.
|
||||||
|
rows: rows.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
email: r.email || '',
|
||||||
|
source: r.sourceLabel,
|
||||||
|
title: r.title || '',
|
||||||
|
company: r.company || '',
|
||||||
|
years: r.years ?? '',
|
||||||
|
skills: r.skills.join(', '),
|
||||||
|
match: r.rankScore ?? '',
|
||||||
|
ats: r.aiScore ?? '',
|
||||||
|
added: r.added ? r.added.toLocaleDateString() : '',
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
toast(`Exported ${rows.length} CV${rows.length === 1 ? '' : 's'}`, 'success')
|
||||||
|
} catch {
|
||||||
|
toast('Export failed', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="CV Bank"
|
||||||
|
sub={
|
||||||
|
bankQuery.isSuccess
|
||||||
|
? <>{total} CV{total === 1 ? '' : 's'} held for future roles{selectedJob ? <> · ranked against <strong>{selectedJob.title}</strong></> : null}</>
|
||||||
|
: 'CVs held for future roles'
|
||||||
|
}
|
||||||
|
actions={<>
|
||||||
|
<button className="btn btn-secondary" onClick={exportRows}>
|
||||||
|
<Icon name="download" /> Export
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
||||||
|
<Icon name="upload" /> Add CVs
|
||||||
|
</button>
|
||||||
|
</>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="toolbar-search">
|
||||||
|
<Icon name="search" />
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Search name, email, company, or skill…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
|
||||||
|
<Icon name="filter" /> Filters
|
||||||
|
</button>
|
||||||
|
<div className="spacer" />
|
||||||
|
{/* The "a role just opened, who do we already have" control. This is
|
||||||
|
the moment the bank is meant to be used. */}
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<label className="text-muted text-sm">Rank against job:</label>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={jobPostId}
|
||||||
|
onChange={(e) => setJobPostId(e.target.value)}
|
||||||
|
disabled={jobsQuery.isPending}
|
||||||
|
>
|
||||||
|
<option value="">No job selected</option>
|
||||||
|
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<div
|
||||||
|
className="filter-panel"
|
||||||
|
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
|
||||||
|
>
|
||||||
|
<Facet
|
||||||
|
label="Source"
|
||||||
|
value={filters.source}
|
||||||
|
onChange={(v) => setFilter('source', v)}
|
||||||
|
any="Any source"
|
||||||
|
options={SOURCE_FILTERS}
|
||||||
|
labels={SOURCE_LABELS}
|
||||||
|
/>
|
||||||
|
<Facet
|
||||||
|
label="ATS band"
|
||||||
|
value={filters.band}
|
||||||
|
onChange={(v) => setFilter('band', v)}
|
||||||
|
any="Any band"
|
||||||
|
options={BAND_FILTERS}
|
||||||
|
/>
|
||||||
|
<Facet
|
||||||
|
label="Minimum years"
|
||||||
|
value={filters.years}
|
||||||
|
onChange={(v) => setFilter('years', v)}
|
||||||
|
any="Any experience"
|
||||||
|
options={YEARS_FILTERS}
|
||||||
|
labels={Object.fromEntries(YEARS_FILTERS.map((y) => [y, `${y}+ years`]))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bankQuery.isPending && (
|
||||||
|
<div className="card-body"><SkeletonRows rows={6} /></div>
|
||||||
|
)}
|
||||||
|
{bankQuery.isError && (
|
||||||
|
<div className="card-body">
|
||||||
|
<EmptyState icon="file" title="Couldn’t load the CV Bank">
|
||||||
|
{friendlyAuthError(bankQuery.error, 'Request failed')}
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bankQuery.isSuccess && (
|
||||||
|
<div className="dt">
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data">
|
||||||
|
<DataTableHead columns={columns} sort={t.sort} toggleSort={t.toggleSort} />
|
||||||
|
<tbody>
|
||||||
|
{t.pageRows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length}>
|
||||||
|
{search || filters.source || filters.band || filters.years ? (
|
||||||
|
<EmptyState title="No matches">
|
||||||
|
No held CV matches these filters. Try widening them.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<EmptyState icon="file" title="The CV Bank is empty">
|
||||||
|
Import CVs with “No job — store in CV bank” selected, and
|
||||||
|
rejected applicants who scored well will show up here too.
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
t.pageRows.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>
|
||||||
|
<div className="user-cell">
|
||||||
|
<Avatar name={r.name} initials={initialsOf(r.name)} color={avatarColor(r.name)} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="cell-primary">{r.name}</div>
|
||||||
|
<div className="cell-sub">{r.email || r.fileName || 'No email detected'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge className={SOURCE_BADGE[r.source] || 'b-gray'}>{r.sourceLabel}</Badge>
|
||||||
|
{r.lastJobTitle && (
|
||||||
|
<div className="cell-sub cell-clip" title={r.lastJobTitle}>
|
||||||
|
applied for {r.lastJobTitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.expiresAt && (
|
||||||
|
<div className="cell-sub">{candidatesApi.expiryLabel(r.expiresAt)}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="text-sm cell-clip" title={r.title || undefined}>{r.title || '—'}</div>
|
||||||
|
{r.company && <div className="cell-sub cell-clip" title={r.company}>{r.company}</div>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-sm">{r.years == null ? '—' : r.years}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{r.skills.length ? (
|
||||||
|
<div className="k-tags">
|
||||||
|
{r.skills.slice(0, SKILL_CHIPS).map((s) => (
|
||||||
|
<span className="tag" key={s}>{s}</span>
|
||||||
|
))}
|
||||||
|
{r.skills.length > SKILL_CHIPS && (
|
||||||
|
<span className="tag" title={r.skills.slice(SKILL_CHIPS).join(', ')}>
|
||||||
|
+{r.skills.length - SKILL_CHIPS}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted text-sm">None extracted</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td><MatchCell rank={r.rankScore} hasJob={Boolean(jobPostId)} /></td>
|
||||||
|
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
|
||||||
|
<td>
|
||||||
|
<span className="text-sm">{r.added ? r.added.toLocaleDateString() : '—'}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
|
||||||
|
{r.isStoredCv && <OpenResumeButton filePath={r.filePath} className="btn btn-secondary btn-sm" />}
|
||||||
|
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
|
||||||
|
<Icon name="eye" />
|
||||||
|
</button>
|
||||||
|
{r.isStoredCv && (<>
|
||||||
|
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
|
||||||
|
<Icon name="download" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="act-btn"
|
||||||
|
data-tip="Score against a job"
|
||||||
|
aria-label="Score this CV against a job"
|
||||||
|
onClick={() => setScoreFor(r)}
|
||||||
|
>
|
||||||
|
<Icon name="target" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="act-btn"
|
||||||
|
data-tip="Assign to a job"
|
||||||
|
aria-label="Assign this CV to a job"
|
||||||
|
onClick={() => setAssignFor(r)}
|
||||||
|
>
|
||||||
|
<Icon name="briefcase" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="act-btn"
|
||||||
|
data-tip="Remove"
|
||||||
|
aria-label="Remove CV from bank"
|
||||||
|
disabled={removing.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Remove “${r.fileName || r.name}” from the CV Bank? The file is deleted permanently.`)) {
|
||||||
|
removing.mutate(r.recordId)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
from={from}
|
||||||
|
to={to}
|
||||||
|
total={total}
|
||||||
|
page={currentPage}
|
||||||
|
pages={pages}
|
||||||
|
setPage={(p) => setSkip((p - 1) * pageSize)}
|
||||||
|
pageButtons={pageWindow(currentPage, pages)}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={(n) => setPageSize(n)}
|
||||||
|
pageSizeMax={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted text-sm mt-18">
|
||||||
|
<Icon name="info" /> <strong>Match</strong> is free keyword overlap against the selected
|
||||||
|
job — it orders this list, it does not assess anyone. <strong>ATS</strong> is a real
|
||||||
|
scored result and only appears once someone runs one.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{scoreFor && (
|
||||||
|
<JobPickerModal
|
||||||
|
title="Score against a job"
|
||||||
|
subtitle={`Run the real ATS score for ${scoreFor.name}`}
|
||||||
|
note="This calls the scoring model and costs money. The result lands in Candidates like any other scored CV."
|
||||||
|
confirmLabel="Score CV"
|
||||||
|
jobs={jobs}
|
||||||
|
defaultJobId={jobPostId}
|
||||||
|
pending={scoring.isPending}
|
||||||
|
onClose={() => setScoreFor(null)}
|
||||||
|
onConfirm={(jobId) => scoring.mutate({ jobId, ids: [scoreFor.recordId] })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assignFor && (
|
||||||
|
<JobPickerModal
|
||||||
|
title="Assign to a job"
|
||||||
|
subtitle={`Move ${assignFor.name} onto a job post`}
|
||||||
|
note="The CV leaves the bank and enters the pipeline as an application. It is not scored by this action."
|
||||||
|
confirmLabel="Assign"
|
||||||
|
jobs={jobs}
|
||||||
|
defaultJobId={jobPostId}
|
||||||
|
pending={assigning.isPending}
|
||||||
|
onClose={() => setAssignFor(null)}
|
||||||
|
onConfirm={(jobId) => assigning.mutate({ id: assignFor.recordId, jobId })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<Modal
|
||||||
|
title={preview.name}
|
||||||
|
subtitle="CV preview"
|
||||||
|
size="modal-lg"
|
||||||
|
onClose={closePreview}
|
||||||
|
footer={<button className="btn btn-secondary" onClick={closePreview}>Close</button>}
|
||||||
|
>
|
||||||
|
{/* Blob URL re-typed to application/pdf so the browser's built-in
|
||||||
|
viewer renders inline instead of triggering a download. */}
|
||||||
|
<iframe
|
||||||
|
src={preview.url}
|
||||||
|
title={`Preview of ${preview.name}`}
|
||||||
|
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 10, background: 'var(--bg-sunken)' }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Facet({ label, value, onChange, any, options, labels }) {
|
||||||
|
return (
|
||||||
|
<div className="form-field">
|
||||||
|
<label>{label}</label>
|
||||||
|
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
<option value="">{any}</option>
|
||||||
|
{options.map((o) => <option key={o} value={o}>{labels?.[o] ?? o}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by the score and assign actions — both need exactly one job post. */
|
||||||
|
function JobPickerModal({ title, subtitle, note, confirmLabel, jobs, defaultJobId, pending, onClose, onConfirm }) {
|
||||||
|
const [jobId, setJobId] = useState(defaultJobId || (jobs[0]?.id ?? ''))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={<>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose} disabled={pending}>Cancel</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={pending || !jobId}
|
||||||
|
onClick={() => onConfirm(jobId)}
|
||||||
|
>
|
||||||
|
<Icon name="check" /> {pending ? 'Working…' : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<EmptyState icon="briefcase" title="No job posts yet">
|
||||||
|
Create a job post first — there is nothing to match against.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="form-field">
|
||||||
|
<label>Job post</label>
|
||||||
|
<select value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
||||||
|
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted text-sm">{note}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,10 +12,9 @@
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import Modal from '../ui/Modal'
|
|
||||||
import OpenResumeButton from '../ui/OpenResumeButton'
|
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
|
|
@ -24,7 +23,6 @@ import { qk } from '../lib/queryKeys'
|
||||||
import { fmtDate } from '../lib/format'
|
import { fmtDate } from '../lib/format'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
import * as candidatesApi from '../api/candidates'
|
import * as candidatesApi from '../api/candidates'
|
||||||
import * as s3Api from '../api/s3'
|
|
||||||
|
|
||||||
/* Sentinel for the job picker: store CVs without scoring or assignment. */
|
/* Sentinel for the job picker: store CVs without scoring or assignment. */
|
||||||
const NO_JOB = '__none__'
|
const NO_JOB = '__none__'
|
||||||
|
|
@ -33,14 +31,14 @@ const SCORE_STEPS = [
|
||||||
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
|
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
|
||||||
{ i: 'target', t: 'ATS scoring', d: 'LLM match score with matched & missing skills vs the selected job' },
|
{ i: 'target', t: 'ATS scoring', d: 'LLM match score with matched & missing skills vs the selected job' },
|
||||||
{ i: 'users', t: 'Duplicate detection', d: 'Re-uploading the same file updates its existing record' },
|
{ i: 'users', t: 'Duplicate detection', d: 'Re-uploading the same file updates its existing record' },
|
||||||
{ i: 'user-plus', t: 'Saved to pool', d: 'Results persist — see Candidates and Talent Pool' },
|
{ i: 'user-plus', t: 'Saved to pool', d: 'Results persist — see Candidates' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const STORE_STEPS = [
|
const STORE_STEPS = [
|
||||||
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
|
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
|
||||||
{ i: 'users', t: 'Details captured', d: 'Candidate email is picked up when the CV contains one' },
|
{ i: 'users', t: 'Profile extracted', d: 'Email, skills, current role, company and years are read off the CV' },
|
||||||
{ i: 'target', t: 'Nothing else happens', d: 'No scoring, no candidate account, no inbox entry — just stored' },
|
{ i: 'target', t: 'No ATS score', d: 'Scoring costs money and needs a job — it happens later, on the ones you pick' },
|
||||||
{ i: 'user-plus', t: 'Saved to CV bank', d: 'Browse, download or remove stored CVs in the bank below' },
|
{ i: 'user-plus', t: 'Saved to CV Bank', d: 'Searchable by extracted skills and years, and ranked automatically when a job opens' },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function fetchJobs() {
|
async function fetchJobs() {
|
||||||
|
|
@ -60,6 +58,7 @@ let rowSeq = 0
|
||||||
export default function CvImport() {
|
export default function CvImport() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
|
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
|
||||||
const jobs = jobsQuery.data ?? []
|
const jobs = jobsQuery.data ?? []
|
||||||
|
|
||||||
|
|
@ -337,9 +336,24 @@ export default function CvImport() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No-Job mode swaps the scored grid for the bank itself. */}
|
{/* Browsing the bank lives on its own screen now — it holds silver
|
||||||
|
medalists and job ranking too, none of which belongs under an upload
|
||||||
|
form. This mode just points at it. */}
|
||||||
{noJobMode ? (
|
{noJobMode ? (
|
||||||
<CvBank />
|
<div className="card mt-18">
|
||||||
|
<div className="card-body">
|
||||||
|
<EmptyState icon="talent" title="Stored CVs live in the CV Bank">
|
||||||
|
Uploads land there with no job, no score and no inbox entry. The bank
|
||||||
|
also holds applicants who scored well and were not hired, and it ranks
|
||||||
|
everyone against a job you pick.
|
||||||
|
<div className="mt-18">
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate('/cvbank')}>
|
||||||
|
<Icon name="talent" /> Open the CV Bank
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Everything ever scored against the selected job — this batch, earlier
|
/* Everything ever scored against the selected job — this batch, earlier
|
||||||
uploads and synced inbox CVs alike. The scoring mutation invalidates
|
uploads and synced inbox CVs alike. The scoring mutation invalidates
|
||||||
|
|
@ -349,147 +363,3 @@ export default function CvImport() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The stored-CV bank — CVs imported with No job. Unassigned rows live here;
|
|
||||||
assigning a job in Job Matching sets job_post_id and they leave this list. */
|
|
||||||
function CvBank() {
|
|
||||||
const { toast } = useToast()
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const [preview, setPreview] = useState(null) // { name, url } — url is an object URL we own
|
|
||||||
const bankQuery = useQuery({
|
|
||||||
queryKey: qk.cvBank.list(),
|
|
||||||
queryFn: () => candidatesApi.listCvBank({ top: 200 }),
|
|
||||||
})
|
|
||||||
const rows = Array.isArray(bankQuery.data?.data) ? bankQuery.data.data : []
|
|
||||||
|
|
||||||
async function view(row) {
|
|
||||||
const tab = s3Api.canOpen(row.file_path) ? window.open('about:blank', '_blank') : null
|
|
||||||
try {
|
|
||||||
if (s3Api.canOpen(row.file_path)) {
|
|
||||||
await s3Api.openPdf(row.file_path, { tab })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const url = await candidatesApi.viewCvBankCv(row.id)
|
|
||||||
if (!url) {
|
|
||||||
toast('The CV file could not be found', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setPreview({ name: row.file_name || 'CV', url })
|
|
||||||
} catch (err) {
|
|
||||||
if (tab && !tab.closed) tab.close()
|
|
||||||
toast(friendlyAuthError(err, 'Could not open the CV'), 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePreview() {
|
|
||||||
if (preview) URL.revokeObjectURL(preview.url)
|
|
||||||
setPreview(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removing = useMutation({
|
|
||||||
mutationFn: (id) => candidatesApi.deleteCvBankCv(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
|
||||||
toast('CV removed from the bank', 'success')
|
|
||||||
},
|
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
async function download(row) {
|
|
||||||
try {
|
|
||||||
await candidatesApi.downloadCvBankCv(row.id)
|
|
||||||
} catch (err) {
|
|
||||||
toast(friendlyAuthError(err, 'Could not download the CV'), 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card mt-18">
|
|
||||||
<div className="card-head">
|
|
||||||
<div>
|
|
||||||
<h3>CV Bank</h3>
|
|
||||||
<span className="ch-sub">
|
|
||||||
{bankQuery.isSuccess ? `${bankQuery.data?.total ?? rows.length} stored CV${(bankQuery.data?.total ?? rows.length) === 1 ? '' : 's'} · no job attached` : 'Stored CVs with no job attached'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
{bankQuery.isLoading && <p className="text-muted text-sm">Loading stored CVs…</p>}
|
|
||||||
{bankQuery.isError && (
|
|
||||||
<p className="text-muted text-sm">{friendlyAuthError(bankQuery.error, 'Could not load the CV bank')}</p>
|
|
||||||
)}
|
|
||||||
{bankQuery.isSuccess && rows.length === 0 && (
|
|
||||||
<EmptyState icon="file" title="The CV bank is empty">
|
|
||||||
Drop CVs above with “No job — store in CV bank” selected and they will be kept here.
|
|
||||||
</EmptyState>
|
|
||||||
)}
|
|
||||||
{rows.map((r) => (
|
|
||||||
<div className="upload-row" key={r.id}>
|
|
||||||
<span className="attach-icn" style={{ width: 38, height: 38 }}><Icon name="file" /></span>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div className="fw-600 text-sm">{r.file_name || 'CV'}</div>
|
|
||||||
<div className="cell-sub">
|
|
||||||
{r.candidate_email || 'No email detected'}
|
|
||||||
{r.created_at ? ` · added ${fmtDate(r.created_at)}` : ''}
|
|
||||||
</div>
|
|
||||||
{r.linkedin_url ? (
|
|
||||||
<div className="cell-sub" style={{ marginTop: 2 }}>
|
|
||||||
<a href={r.linkedin_url} target="_blank" rel="noopener noreferrer">{r.linkedin_url}</a>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{r.file_path ? (
|
|
||||||
<div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}>
|
|
||||||
{r.file_path}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="cell-sub" style={{ marginTop: 2 }}>No S3 path yet</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
|
|
||||||
<OpenResumeButton filePath={r.file_path} className="btn btn-secondary btn-sm" />
|
|
||||||
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
|
|
||||||
<Icon name="eye" />
|
|
||||||
</button>
|
|
||||||
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
|
|
||||||
<Icon name="download" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="act-btn"
|
|
||||||
data-tip="Remove"
|
|
||||||
aria-label="Remove CV from bank"
|
|
||||||
disabled={removing.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(`Remove “${r.file_name}” from the CV bank? The file is deleted permanently.`)) {
|
|
||||||
removing.mutate(r.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{preview && (
|
|
||||||
<Modal
|
|
||||||
title={preview.name}
|
|
||||||
subtitle="CV preview"
|
|
||||||
size="modal-lg"
|
|
||||||
onClose={closePreview}
|
|
||||||
footer={
|
|
||||||
<button className="btn btn-secondary" onClick={closePreview}>Close</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Blob URL re-typed to application/pdf, so the browser's built-in
|
|
||||||
viewer renders inline instead of triggering a download. */}
|
|
||||||
<iframe
|
|
||||||
src={preview.url}
|
|
||||||
title={`Preview of ${preview.name}`}
|
|
||||||
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 10, background: 'var(--bg-sunken)' }}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS }
|
||||||
/** Typing must not put a query key (and a skeleton) on screen per keystroke. */
|
/** Typing must not put a query key (and a skeleton) on screen per keystroke. */
|
||||||
const SEARCH_DEBOUNCE_MS = 300
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
/** Sheet Forms link filters. '' = any, 'yes' / 'no' are both real filters. */
|
||||||
|
const EMPTY_FORM_FILTERS = { hasLinkedin: '', hasResume: '' }
|
||||||
|
|
||||||
/** "Updated 4 min ago" under the search box — the honest label for a cached list. */
|
/** "Updated 4 min ago" under the search box — the honest label for a cached list. */
|
||||||
function agoLabel(ts) {
|
function agoLabel(ts) {
|
||||||
if (!ts) return null
|
if (!ts) return null
|
||||||
|
|
@ -827,6 +830,74 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sheet Forms link filters, stacked for the queue column.
|
||||||
|
*
|
||||||
|
* NOT the shared `.filter-panel` used by Candidates and CvBank: that lays out in
|
||||||
|
* a four-column grid whose breakpoints watch the VIEWPORT, while this column is
|
||||||
|
* `minmax(280px, 34%)`. On any wide screen it would try to fit four columns into
|
||||||
|
* about four hundred pixels. This stacks instead, like the Progress sidebar.
|
||||||
|
*
|
||||||
|
* Collapsed by default because two permanent selects eat scarce vertical space
|
||||||
|
* above the queue — but the toggle carries the active count, because a collapsed
|
||||||
|
* panel silently hiding a filter is how a recruiter concludes the list is broken.
|
||||||
|
*
|
||||||
|
* (`Facet` in Candidates.jsx and CvBank.jsx is the same idea in a wider column.
|
||||||
|
* Both files are mid-edit elsewhere, so this is deliberately a local copy rather
|
||||||
|
* than a refactor of theirs.)
|
||||||
|
*/
|
||||||
|
function FormLinkFilters({ filters, active, open, onToggle, onChange, onClear }) {
|
||||||
|
return (
|
||||||
|
<div className="inbox-filters">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${active ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<Icon name="filter" /> Filters{active ? ` (${active})` : ''}
|
||||||
|
</button>
|
||||||
|
{active > 0 && (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={onClear}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{open && (
|
||||||
|
<div className="inbox-filter-stack">
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="inbox-f-linkedin">LinkedIn link</label>
|
||||||
|
<select
|
||||||
|
id="inbox-f-linkedin"
|
||||||
|
value={filters.hasLinkedin}
|
||||||
|
onChange={(e) => onChange('hasLinkedin', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="yes">Has a LinkedIn link</option>
|
||||||
|
<option value="no">No LinkedIn link</option>
|
||||||
|
</select>
|
||||||
|
{/* The form field is free text and nothing validates it on the way
|
||||||
|
in, so this matches on the domain and cannot promise the link
|
||||||
|
actually resolves to a profile. Say so rather than imply more. */}
|
||||||
|
<span className="cell-sub">Matches the link text, not a verified profile.</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="inbox-f-resume">Resume link</label>
|
||||||
|
<select
|
||||||
|
id="inbox-f-resume"
|
||||||
|
value={filters.hasResume}
|
||||||
|
onChange={(e) => onChange('hasResume', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="yes">Has a resume link</option>
|
||||||
|
<option value="no">No resume link</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Updated 4 min ago" + Refresh, under the search box.
|
* "Updated 4 min ago" + Refresh, under the search box.
|
||||||
*
|
*
|
||||||
|
|
@ -896,6 +967,18 @@ export default function Inbox() {
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [q])
|
}, [q])
|
||||||
|
|
||||||
|
// '' means no filter; 'yes' / 'no' are both real filters. Finding the rows
|
||||||
|
// MISSING a link is half the reason this exists, so 'no' cannot collapse into
|
||||||
|
// "unset" the way a checkbox would force it to.
|
||||||
|
const [formFilters, setFormFilters] = useState(EMPTY_FORM_FILTERS)
|
||||||
|
const [showFormFilters, setShowFormFilters] = useState(false)
|
||||||
|
const activeFormFilters = Object.values(formFilters).filter(Boolean).length
|
||||||
|
const setFormFilter = useCallback((key, value) => {
|
||||||
|
setFormFilters((f) => ({ ...f, [key]: value }))
|
||||||
|
setSkip(0) // page 4 of the old result set is meaningless in the new one
|
||||||
|
setSelectedId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const isForms = channel === 'forms'
|
const isForms = channel === 'forms'
|
||||||
// Combined channel: both sources fetched UNPAGED (each endpoint reads a
|
// Combined channel: both sources fetched UNPAGED (each endpoint reads a
|
||||||
// missing top/limit as no LIMIT), merged by date, and paged client-side —
|
// missing top/limit as no LIMIT), merged by date, and paged client-side —
|
||||||
|
|
@ -914,6 +997,18 @@ export default function Inbox() {
|
||||||
...(search ? { search } : {}),
|
...(search ? { search } : {}),
|
||||||
}), [tabFilter, skip, pageSize, search, isAllChannel])
|
}), [tabFilter, skip, pageSize, search, isAllChannel])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sheet Forms only. On the All channel these rows are merged with email ones,
|
||||||
|
* and a filter that silently narrows half a merged list is worse than no
|
||||||
|
* filter at all — the count would drop with no visible reason. Email rows do
|
||||||
|
* carry their own LinkedIn columns, so extending this to All means teaching
|
||||||
|
* the inbox endpoint the same filters. Separate change.
|
||||||
|
*/
|
||||||
|
const linkFilters = useMemo(() => (isForms ? {
|
||||||
|
...(formFilters.hasLinkedin ? { hasLinkedin: formFilters.hasLinkedin === 'yes' } : {}),
|
||||||
|
...(formFilters.hasResume ? { hasResume: formFilters.hasResume === 'yes' } : {}),
|
||||||
|
} : {}), [isForms, formFilters])
|
||||||
|
|
||||||
const formParams = useMemo(() => ({
|
const formParams = useMemo(() => ({
|
||||||
// All channel spans every sheet tab, not just the selected one.
|
// All channel spans every sheet tab, not just the selected one.
|
||||||
sheet: isAllChannel ? undefined : (formSheet || undefined),
|
sheet: isAllChannel ? undefined : (formSheet || undefined),
|
||||||
|
|
@ -921,7 +1016,8 @@ export default function Inbox() {
|
||||||
limit: pageSize === 'all' || isAllChannel ? undefined : pageSize,
|
limit: pageSize === 'all' || isAllChannel ? undefined : pageSize,
|
||||||
...formTabFilter,
|
...formTabFilter,
|
||||||
...(search ? { search } : {}),
|
...(search ? { search } : {}),
|
||||||
}), [formSheet, skip, pageSize, search, formTabFilter, isAllChannel])
|
...linkFilters,
|
||||||
|
}), [formSheet, skip, pageSize, search, formTabFilter, isAllChannel, linkFilters])
|
||||||
|
|
||||||
const applicationsQuery = useQuery({
|
const applicationsQuery = useQuery({
|
||||||
queryKey: qk.mailbox.applications(listParams),
|
queryKey: qk.mailbox.applications(listParams),
|
||||||
|
|
@ -955,10 +1051,19 @@ export default function Inbox() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const formCountsSheet = isAllChannel ? undefined : (formSheet || undefined)
|
const formCountsSheet = isAllChannel ? undefined : (formSheet || undefined)
|
||||||
|
// The badges narrow with the list, so the tab counts describe the rows
|
||||||
|
// actually underneath them. `search` is included for the same reason and
|
||||||
|
// fixes a pre-existing mismatch: typing in the box used to leave the badges
|
||||||
|
// reporting the whole sheet.
|
||||||
|
const formCountsParams = useMemo(() => ({
|
||||||
|
sheet: formCountsSheet,
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
...linkFilters,
|
||||||
|
}), [formCountsSheet, search, linkFilters])
|
||||||
const formCountsQuery = useQuery({
|
const formCountsQuery = useQuery({
|
||||||
queryKey: qk.mailbox.formCounts({ sheet: formCountsSheet }),
|
queryKey: qk.mailbox.formCounts(formCountsParams),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await sheetApi.fetchFormCounts({ sheet: formCountsSheet })
|
const res = await sheetApi.fetchFormCounts(formCountsParams)
|
||||||
return res?.data ?? {}
|
return res?.data ?? {}
|
||||||
},
|
},
|
||||||
enabled: isForms || isAllChannel,
|
enabled: isForms || isAllChannel,
|
||||||
|
|
@ -1154,6 +1259,7 @@ export default function Inbox() {
|
||||||
setQ('')
|
setQ('')
|
||||||
setSearch('') // clear the committed term too, or the new channel's first
|
setSearch('') // clear the committed term too, or the new channel's first
|
||||||
// fetch carries the old channel's search for 300ms
|
// fetch carries the old channel's search for 300ms
|
||||||
|
setFormFilters(EMPTY_FORM_FILTERS)
|
||||||
// Unread is email-only; leave it behind when opening Sheet Forms or All.
|
// Unread is email-only; leave it behind when opening Sheet Forms or All.
|
||||||
if (next !== 'email' && tab === 'Unread') setTab('All Applications')
|
if (next !== 'email' && tab === 'Unread') setTab('All Applications')
|
||||||
selection.clear()
|
selection.clear()
|
||||||
|
|
@ -1465,6 +1571,16 @@ export default function Inbox() {
|
||||||
placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'}
|
placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isForms && (
|
||||||
|
<FormLinkFilters
|
||||||
|
filters={formFilters}
|
||||||
|
active={activeFormFilters}
|
||||||
|
open={showFormFilters}
|
||||||
|
onToggle={() => setShowFormFilters((s) => !s)}
|
||||||
|
onChange={setFormFilter}
|
||||||
|
onClear={() => { setFormFilters(EMPTY_FORM_FILTERS); setSkip(0); setSelectedId(null) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<QueueFreshness at={updatedAt} refreshing={refreshing} onRefresh={refreshQueue} />
|
<QueueFreshness at={updatedAt} refreshing={refreshing} onRefresh={refreshQueue} />
|
||||||
</div>
|
</div>
|
||||||
<div className="inbox-list-body">
|
<div className="inbox-list-body">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* The profile modal for candidate rows on /candidates (identity from
|
/* The profile modal for candidate rows on /candidates (identity from
|
||||||
/candidate/fetch/users, detail from GET /candidate/fetch?user_id=). Distinct
|
/candidate/fetch/users, detail from GET /candidate/fetch?user_id=). Distinct
|
||||||
from CandidateProfile.jsx, which renders the 8-tab TalentPool modal.
|
from CandidateProfile.jsx, which renders the 8-tab modal.
|
||||||
|
|
||||||
SCORING IS A THIRD, INDEPENDENT READ: GET /pipeline/candidate/score/fetch,
|
SCORING IS A THIRD, INDEPENDENT READ: GET /pipeline/candidate/score/fetch,
|
||||||
the candidate's current ats_results row. It has to be, because the detail
|
the candidate's current ats_results row. It has to be, because the detail
|
||||||
|
|
|
||||||
|
|
@ -1,403 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
Talent Pool — the prototype's card grid, now fed by GET /candidate/fetch.
|
|
||||||
|
|
||||||
The layout, the toolbar, the card and the 8-tab profile modal are the
|
|
||||||
originals, unchanged. Only the data source moved.
|
|
||||||
|
|
||||||
The endpoint returns name, email, experience, application_status, suggested
|
|
||||||
job titles and the attached job_posts (with department). It has no skills or
|
|
||||||
currentCompany on list rows — those still come from the seed overlay. Each
|
|
||||||
record is therefore OVERLAID on a seed candidate: real values win, seed fills
|
|
||||||
the rest, so the card renders exactly as it always did.
|
|
||||||
|
|
||||||
Clicking a card opens CandidateProfile in place. It used to deep-link into
|
|
||||||
/candidates, which stopped resolving once the ids became real user_ids.
|
|
||||||
|
|
||||||
The card is seed-overlaid, but the MODAL is not: it re-reads the candidate by
|
|
||||||
`userId` through GET /candidate/fetch?user_id=, which is a far richer payload
|
|
||||||
than the list rows — résumé text, the agent's verdict, documents, and the
|
|
||||||
interviews / notes / activity / feedback collections, all writable from their
|
|
||||||
own tabs. That switch happens inside CandidateProfile; passing `userId` is the
|
|
||||||
whole trigger.
|
|
||||||
|
|
||||||
A SECOND read fires on that same click: GET /pipeline/candidate/score/fetch,
|
|
||||||
the pipeline board's score endpoint, which returns the candidate's current
|
|
||||||
ats_results row. The two run concurrently — this screen owns the score call,
|
|
||||||
CandidateProfile owns the detail call — and the score wins over both the
|
|
||||||
list row's denormalised ai_score and the seed placeholder. It is deliberately
|
|
||||||
NOT fetched for the grid: 100 cards would be 100 requests, and the card only
|
|
||||||
ever needed a number good enough to sort by eye.
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import { DEFAULT_PAGE_SIZE, PageSizeField } from '../ui/DataTable'
|
|
||||||
import PageHeader from '../ui/PageHeader'
|
|
||||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
|
||||||
import { useToast } from '../ui/Toast'
|
|
||||||
import CandidateProfile from './CandidateProfile'
|
|
||||||
import { AtsMatch } from './Candidates'
|
|
||||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
|
||||||
import { qk } from '../lib/queryKeys'
|
|
||||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
|
||||||
import * as candidatesApi from '../api/candidates'
|
|
||||||
import * as jobPostsApi from '../api/jobPosts'
|
|
||||||
import * as pipelineApi from '../api/pipeline'
|
|
||||||
import { ReappliedBadge } from '../components/ReapplicantHistory'
|
|
||||||
import { fmtDate } from '../lib/format'
|
|
||||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
|
||||||
|
|
||||||
/** Backend GET /candidate/fetch caps `limit` at 100. */
|
|
||||||
const PAGE_SIZE_MAX = 100
|
|
||||||
|
|
||||||
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
|
||||||
|
|
||||||
/** "6 years" -> 6. The column is free text, so anything unparseable defers to seed. */
|
|
||||||
function years(value) {
|
|
||||||
const n = parseInt(value, 10)
|
|
||||||
return Number.isFinite(n) ? n : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Distinct departments from the candidate's assigned + suggested job posts.
|
|
||||||
* Seed templates also carry a department, but that is a prototype leftover and
|
|
||||||
* must not drive the toolbar filter — it would never match /job/departments/fetch.
|
|
||||||
*/
|
|
||||||
function departmentsOf(row) {
|
|
||||||
const seen = new Set()
|
|
||||||
const out = []
|
|
||||||
const add = (value) => {
|
|
||||||
const d = typeof value === 'string' ? value : ''
|
|
||||||
if (!d || seen.has(d)) return
|
|
||||||
seen.add(d)
|
|
||||||
out.push(d)
|
|
||||||
}
|
|
||||||
add(row.assigned_job_post?.department)
|
|
||||||
for (const jp of row.job_posts || []) add(jp.department)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One API record overlaid on one seed candidate.
|
|
||||||
*
|
|
||||||
* `id` deliberately stays the SEED id: the favourite/advance seed mutations key
|
|
||||||
* off it, so a UUID here would silently drop those writes. The real identifier
|
|
||||||
* rides along on `userId`, and that is what the profile modal reads its live
|
|
||||||
* record with.
|
|
||||||
*/
|
|
||||||
function merge(row, template) {
|
|
||||||
const name = row.name || template.name
|
|
||||||
const title = (row.job_posts || []).map((j) => j.title).find(Boolean)
|
|
||||||
|| row.job_title
|
|
||||||
|| row.current_title
|
|
||||||
const stage = pipelineApi.STAGE_FROM_STATUS[row.application_status] || template.stage
|
|
||||||
const experience = years(row.experience)
|
|
||||||
const departments = departmentsOf(row)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...template,
|
|
||||||
userId: row.user_id,
|
|
||||||
name,
|
|
||||||
initials: initialsOf(name),
|
|
||||||
color: avatarColor(name),
|
|
||||||
email: row.email || template.email,
|
|
||||||
experience: experience ?? template.experience,
|
|
||||||
stage,
|
|
||||||
status: stage,
|
|
||||||
currentTitle: title || template.currentTitle,
|
|
||||||
jobTitle: title || template.jobTitle,
|
|
||||||
// Live job-post departments only. Seed department is left on `department`
|
|
||||||
// for the seed-only profile modal, but the filter reads `departments`.
|
|
||||||
departments,
|
|
||||||
department: departments[0] || template.department,
|
|
||||||
jobIds: candidatesApi.jobIdsOf(row),
|
|
||||||
// Prefer real Form / platform tags from manual_upload; seed only as fallback.
|
|
||||||
source: row.source || template.source,
|
|
||||||
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
|
|
||||||
// resolved server-side; null means the scoring engine never scored this
|
|
||||||
// person, and the card renders nothing rather than a plausible fake number
|
|
||||||
// a recruiter would read as a real match.
|
|
||||||
aiScore: row.ai_score ?? null,
|
|
||||||
recommendation: row.recommendation ?? null,
|
|
||||||
isReapplicant: Boolean(row.is_reapplicant),
|
|
||||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `inbox` holds one row per (user, message), so a candidate who mailed us three
|
|
||||||
* times arrives three times. Collapse onto the person before pairing templates,
|
|
||||||
* otherwise one candidate would occupy three cards and three seed identities.
|
|
||||||
*/
|
|
||||||
function buildPool(rows, templates) {
|
|
||||||
if (!templates.length) return []
|
|
||||||
const byPerson = new Map()
|
|
||||||
for (const row of rows) {
|
|
||||||
const key = row.user_id ?? `inbox-${row.inbox_id}`
|
|
||||||
if (!byPerson.has(key)) byPerson.set(key, row)
|
|
||||||
}
|
|
||||||
return [...byPerson.values()].map((row, i) => merge(row, templates[i % templates.length]))
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TalentPool() {
|
|
||||||
const { toast } = useToast()
|
|
||||||
const { data: templates = [] } = useQuery(seedQuery('candidates'))
|
|
||||||
const updateCandidates = useSeedMutation('candidates')
|
|
||||||
|
|
||||||
const [q, setQ] = useState('')
|
|
||||||
const [dept, setDept] = useState('')
|
|
||||||
const [jobId, setJobId] = useState('')
|
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
|
||||||
const [profileFor, setProfileFor] = useState(null)
|
|
||||||
const [atsFor, setAtsFor] = useState(null)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
// Real candidates get the full profile PAGE; the in-place modal remains only
|
|
||||||
// for seed cards that have no user account to deep-link.
|
|
||||||
const openProfile = (c) => {
|
|
||||||
if (c.userId) navigate(`/candidate/${c.userId}`)
|
|
||||||
else setProfileFor(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: qk.candidates.list({ limit: pageSize, assignedJobPostId: jobId || undefined }),
|
|
||||||
queryFn: () => candidatesApi.list({ limit: pageSize, assignedJobPostId: jobId || undefined }),
|
|
||||||
})
|
|
||||||
const deptsQuery = useQuery({
|
|
||||||
queryKey: qk.jobPosts.departments(),
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await jobPostsApi.listDepartments()
|
|
||||||
return Array.isArray(res?.data) ? res.data : []
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const departments = deptsQuery.data ?? []
|
|
||||||
const jobsQuery = useQuery({
|
|
||||||
queryKey: qk.jobPosts.list({ top: 100, scope: 'talent-pool' }),
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await candidatesApi.listJobs()
|
|
||||||
const rows = Array.isArray(res?.data) ? res.data : []
|
|
||||||
return rows
|
|
||||||
.filter((row) => row && row.id != null)
|
|
||||||
.map((row) => ({
|
|
||||||
id: String(row.id),
|
|
||||||
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const jobs = jobsQuery.data ?? []
|
|
||||||
|
|
||||||
const pool = useMemo(
|
|
||||||
() => buildPool(candidatesApi.toRows(query.data), templates),
|
|
||||||
[query.data, templates],
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The clicked candidate's current ATS score, from the pipeline board's own
|
|
||||||
* endpoint. `enabled` is the "only on click" rule: with no open profile there
|
|
||||||
* is no userId, and the query never runs. React Query caches it per user, so
|
|
||||||
* re-opening the same card repaints from cache.
|
|
||||||
*
|
|
||||||
* Sent WITHOUT job_post_id on purpose. The pool is a cross-job view — it has
|
|
||||||
* no job filter and most rows carry no assigned post — so pinning would only
|
|
||||||
* ever hide a score that exists under some other post. Unpinned, the endpoint
|
|
||||||
* answers with the newest current score the candidate has anywhere.
|
|
||||||
*/
|
|
||||||
const scoreQuery = useQuery({
|
|
||||||
queryKey: qk.pipeline.candidateScore({ userId: profileFor?.userId ?? null }),
|
|
||||||
queryFn: () => pipelineApi.fetchCandidateScore({ userId: profileFor.userId }),
|
|
||||||
select: pipelineApi.toAtsScore,
|
|
||||||
enabled: Boolean(profileFor?.userId),
|
|
||||||
})
|
|
||||||
|
|
||||||
// A candidate with no ats_results row answers `null`, which must read as "no
|
|
||||||
// live score" and leave the existing value alone — not as a score of zero.
|
|
||||||
const atsScore = scoreQuery.data?.overall_score ?? null
|
|
||||||
|
|
||||||
const list = useMemo(
|
|
||||||
() =>
|
|
||||||
pool.filter((c) => {
|
|
||||||
if (dept && !(c.departments || []).includes(dept)) return false
|
|
||||||
if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
|
|
||||||
return true
|
|
||||||
}),
|
|
||||||
[pool, q, dept],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Both mirror Candidates.jsx so a change made here shows up there too. The
|
|
||||||
// card renders neither favourite nor stage, so only the open modal restates.
|
|
||||||
// Favourite is a real PATCH once the modal holds a userId; this seed path is
|
|
||||||
// the fallback for inbox rows that were never linked to a user.
|
|
||||||
function toggleFav(c) {
|
|
||||||
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
|
|
||||||
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
|
|
||||||
toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success')
|
|
||||||
}
|
|
||||||
|
|
||||||
function advance(c) {
|
|
||||||
const i = STAGE_ORDER.indexOf(c.stage)
|
|
||||||
if (i === -1 || i >= STAGE_ORDER.length - 1) {
|
|
||||||
toast(`${c.name} cannot be advanced further`, 'warning')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const stage = STAGE_ORDER[i + 1]
|
|
||||||
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x)))
|
|
||||||
setProfileFor((p) => (p && p.id === c.id ? { ...p, stage, status: stage } : p))
|
|
||||||
toast(`${c.name} moved to ${stage}`, 'success')
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CSV of the FILTERED grid, built client-side — there is no /candidate export
|
|
||||||
endpoint (jobs and reports each own theirs). Exporting `list` rather than
|
|
||||||
`pool` means the file always matches what the recruiter is looking at,
|
|
||||||
search, job, and department filter included. Company/skills are seed-overlay
|
|
||||||
values, same as the cards render. */
|
|
||||||
async function exportCsv() {
|
|
||||||
if (!list.length) {
|
|
||||||
toast('Nothing to export — current filters match no candidates', 'warning')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await exportStyledXlsx({
|
|
||||||
filename: `talent-pool-${new Date().toISOString().slice(0, 10)}`,
|
|
||||||
title: 'Talent Pool',
|
|
||||||
subtitle: `${list.length} candidate${list.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
|
|
||||||
columns: [
|
|
||||||
{ header: 'Name', key: 'name', width: 24 },
|
|
||||||
{ header: 'Email', key: 'email', width: 28 },
|
|
||||||
{ header: 'Current Title', key: 'title', width: 24 },
|
|
||||||
{ header: 'Company', key: 'company', width: 20 },
|
|
||||||
{ header: 'Departments', key: 'departments', width: 22 },
|
|
||||||
{ header: 'Stage', key: 'stage', width: 13 },
|
|
||||||
{ header: 'Experience (yrs)', key: 'experience', width: 14 },
|
|
||||||
{ header: 'Source', key: 'source', width: 16 },
|
|
||||||
{ header: 'AI Score', key: 'aiScore', width: 10 },
|
|
||||||
{ header: 'Skills', key: 'skills', width: 40 },
|
|
||||||
],
|
|
||||||
rows: list.map((c) => ({
|
|
||||||
name: c.name, email: c.email, title: c.currentTitle, company: c.currentCompany,
|
|
||||||
departments: (c.departments || []).join('; '), stage: c.stage,
|
|
||||||
experience: c.experience, source: c.source, aiScore: c.aiScore ?? '',
|
|
||||||
skills: (c.skills || []).join('; '),
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
toast(`Exported ${list.length} candidate${list.length === 1 ? '' : 's'}`, 'success')
|
|
||||||
} catch {
|
|
||||||
toast('Export failed', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<PageHeader
|
|
||||||
title="Talent Pool"
|
|
||||||
sub={<>{pool.length} silver-medalists & passive candidates to re-engage</>}
|
|
||||||
actions={
|
|
||||||
<button className="btn btn-primary" onClick={() => toast('Talent campaign created', 'success')}>
|
|
||||||
<Icon name="send" /> Start Campaign
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="card mb-18">
|
|
||||||
<div className="card-body" style={{ padding: 16 }}>
|
|
||||||
<div className="toolbar" style={{ marginBottom: 0 }}>
|
|
||||||
<div className="toolbar-search">
|
|
||||||
<Icon name="search" />
|
|
||||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
|
|
||||||
</div>
|
|
||||||
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
|
||||||
<option value="">All Jobs</option>
|
|
||||||
{jobs.map((j) => (
|
|
||||||
<option key={j.id} value={j.id}>{j.title}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
|
||||||
<option value="">All Departments</option>
|
|
||||||
{departments.map((d) => <option key={d} value={d}>{d}</option>)}
|
|
||||||
</select>
|
|
||||||
<button className="btn btn-secondary" onClick={exportCsv}>
|
|
||||||
<Icon name="download" /> Export
|
|
||||||
</button>
|
|
||||||
<PageSizeField
|
|
||||||
value={pageSize}
|
|
||||||
onChange={setPageSize}
|
|
||||||
max={PAGE_SIZE_MAX}
|
|
||||||
label="Show"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid g-3">
|
|
||||||
{list.length === 0 ? (
|
|
||||||
<div style={{ gridColumn: '1/-1' }}>
|
|
||||||
{/* Same slot, same component — a failed fetch must not read as "no results". */}
|
|
||||||
{query.isError ? (
|
|
||||||
<EmptyState title="Could not load talent pool">
|
|
||||||
{friendlyAuthError(query.error, 'Please try again.')}
|
|
||||||
</EmptyState>
|
|
||||||
) : query.isPending ? (
|
|
||||||
<EmptyState title="Loading talent pool…">Fetching candidates.</EmptyState>
|
|
||||||
) : (
|
|
||||||
<EmptyState title="No talent found">Try a different search, job, or department.</EmptyState>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
list.map((c) => (
|
|
||||||
<div
|
|
||||||
key={c.id}
|
|
||||||
className="card"
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => openProfile(c)}
|
|
||||||
>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
|
|
||||||
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div className="lr-title">
|
|
||||||
{c.name}
|
|
||||||
<ReappliedBadge row={c} />
|
|
||||||
</div>
|
|
||||||
<div className="lr-sub">{c.currentTitle}</div>
|
|
||||||
</div>
|
|
||||||
{c.aiScore != null && <ScoreChip score={c.aiScore} />}
|
|
||||||
</div>
|
|
||||||
<div className="k-tags" style={{ marginBottom: 12 }}>
|
|
||||||
{(c.skills ?? []).slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)}
|
|
||||||
</div>
|
|
||||||
<div className="divider" style={{ margin: '12px 0' }} />
|
|
||||||
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
|
|
||||||
<span className="cell-sub"><Icon name="briefcase" /> {c.experience} yrs</span>
|
|
||||||
<span className="cell-sub">{c.currentCompany}</span>
|
|
||||||
<Badge className="b-gray">{c.source}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{atsFor && (
|
|
||||||
<AtsMatch
|
|
||||||
candidate={atsFor}
|
|
||||||
onClose={() => setAtsFor(null)}
|
|
||||||
onProfile={(c) => { setAtsFor(null); openProfile(c) }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{profileFor && (
|
|
||||||
<CandidateProfile
|
|
||||||
candidate={profileFor}
|
|
||||||
atsScore={atsScore}
|
|
||||||
recommendation={scoreQuery.data?.band ?? null}
|
|
||||||
onClose={() => setProfileFor(null)}
|
|
||||||
onAdvance={advance}
|
|
||||||
onToggleFav={toggleFav}
|
|
||||||
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1162,6 +1162,35 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
/* Sheet Forms link filters. Deliberately NOT .filter-panel: that grid is four
|
||||||
|
columns wide and its breakpoints watch the viewport, but this lives in a
|
||||||
|
minmax(280px, 34%) column, so on a wide screen it would pack four columns into
|
||||||
|
~400px. Stack instead — the same shape .progress-sidebar-filters uses in a
|
||||||
|
near-identical column width. */
|
||||||
|
.inbox-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.inbox-filters .btn-sm { padding: 4px 9px; font-size: 12px; }
|
||||||
|
.inbox-filter-stack {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 0 2px;
|
||||||
|
}
|
||||||
|
.inbox-filter-stack .form-field { margin: 0; }
|
||||||
|
.inbox-filter-stack select { width: 100%; }
|
||||||
|
/* The caveat under the LinkedIn select: it has to be readable, not shouted. */
|
||||||
|
.inbox-filter-stack .cell-sub {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.inbox-list-body { position: relative; }
|
.inbox-list-body { position: relative; }
|
||||||
/* Sticky so it stays visible when the refetch starts from a scrolled queue. */
|
/* Sticky so it stays visible when the refetch starts from a scrolled queue. */
|
||||||
.inbox-refresh-bar {
|
.inbox-refresh-bar {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue