From 5b29e2b59274d70bc05d2c6db66d21fcbcc90e62 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Mon, 7 Sep 2026 13:39:20 +0500 Subject: [PATCH] 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 --- backend/.env.example | 12 + backend/README.md | 65 +- backend/employment_agent/decorators.py | 65 +- backend/employment_agent/execute_agent.py | 2 + backend/employment_agent/prompt.py | 34 +- backend/g_sheet/app.py | 17 +- backend/g_sheet/models.py | 70 +- backend/g_sheet/views.py | 18 +- backend/inbox/models.py | 80 +++ backend/job/app.py | 109 ++- backend/job/candidate/bank_tasks.py | 215 ++++++ backend/job/candidate/models.py | 180 ++++- backend/job/candidate/serializers.py | 85 +++ backend/job/candidate/views.py | 232 +++++++ backend/job/job_post/views.py | 25 + backend/matching/__init__.py | 5 + backend/matching/ranking.py | 121 ++++ .../migrations/manual/029_cv_bank_profile.sql | 30 + .../migrations/manual/030_cv_bank_matches.sql | 25 + backend/talent/plugins.py | 85 +-- backend/taskiq_management/broker_setup.py | 2 +- backend/tests/test_analytics_dashboard.py | 98 +++ backend/tests/test_cv_bank_ranking.py | 133 ++++ backend/tests/test_employment_agent.py | 51 +- .../test_employment_extraction_clamps.py | 165 +++++ backend/tests/test_form_data_filters.py | 108 +++ .../tests/test_manager_candidate_serialize.py | 58 ++ frontend/cvbank.test.mjs | 166 +++++ frontend/dist/index.html | 4 +- frontend/inbox-loading.test.mjs | 105 ++- frontend/mobile.test.mjs | 2 +- frontend/package.json | 4 +- frontend/src/App.jsx | 2 +- frontend/src/__smoke__/entry.jsx | 27 +- frontend/src/api/candidates.js | 117 +++- frontend/src/api/sheet.js | 25 +- frontend/src/app/routes.js | 7 +- frontend/src/auth/permissions.js | 2 +- frontend/src/lib/queryKeys.js | 3 +- frontend/src/screens/Candidates.jsx | 4 +- frontend/src/screens/CvBank.jsx | 639 ++++++++++++++++++ frontend/src/screens/CvImport.jsx | 176 +---- frontend/src/screens/Inbox.jsx | 122 +++- .../src/screens/ScoredCandidateProfile.jsx | 2 +- frontend/src/screens/TalentPool.jsx | 403 ----------- frontend/src/styles/styles.css | 29 + 46 files changed, 3215 insertions(+), 714 deletions(-) create mode 100644 backend/job/candidate/bank_tasks.py create mode 100644 backend/matching/__init__.py create mode 100644 backend/matching/ranking.py create mode 100644 backend/migrations/manual/029_cv_bank_profile.sql create mode 100644 backend/migrations/manual/030_cv_bank_matches.sql create mode 100644 backend/tests/test_analytics_dashboard.py create mode 100644 backend/tests/test_cv_bank_ranking.py create mode 100644 backend/tests/test_employment_extraction_clamps.py create mode 100644 backend/tests/test_form_data_filters.py create mode 100644 backend/tests/test_manager_candidate_serialize.py create mode 100644 frontend/cvbank.test.mjs create mode 100644 frontend/src/screens/CvBank.jsx delete mode 100644 frontend/src/screens/TalentPool.jsx diff --git a/backend/.env.example b/backend/.env.example index e7890d6..d1c83cf 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -112,6 +112,18 @@ TASKIQ_IDLE_TIMEOUT_MS=600000 MANUAL_UPLOAD_TO_ADDRESS=manual-cv-upload@hr-ats.local 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 …). FRONTEND_PORT=5173 BACKEND_PORT=8000 diff --git a/backend/README.md b/backend/README.md index 34f5f14..d238b48 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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.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 | +| `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 | **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 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 @@ -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` | | `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 @@ -990,7 +1052,8 @@ LLM failures are logged and skipped; the API still comes up. ```bash 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): diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py index 563b5fd..614a7d3 100644 --- a/backend/employment_agent/decorators.py +++ b/backend/employment_agent/decorators.py @@ -102,6 +102,57 @@ def _clean_phone(value,resume_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): """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_linkedin_url=clamp_field("linkedin_url",_clean_linkedin) 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 @@ -126,8 +179,16 @@ clamp_phone=clamp_field("phone",_clean_phone) @clamp_linkedin_url @prefer_extracted_phone @clamp_phone +@clamp_skills +@clamp_years_experience 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): value=data.get(key) 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"), "linkedin_url":as_str("linkedin_url"), "phone":as_str("phone"), + "skills":data.get("skills") if isinstance(data.get("skills"),list) else [], + "years_experience":data.get("years_experience"), } diff --git a/backend/employment_agent/execute_agent.py b/backend/employment_agent/execute_agent.py index 8d15f90..debac70 100644 --- a/backend/employment_agent/execute_agent.py +++ b/backend/employment_agent/execute_agent.py @@ -24,6 +24,8 @@ async def run_employment_agent(*,resume_text=""): "current_title":CURRENT_TITLE, "linkedin_url":None, "phone":None, + "skills":[], + "years_experience":None, } try: data=await llm_call(prompt(),user_prompt(text),json_mode=True) diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py index b453aff..070367f 100644 --- a/backend/employment_agent/prompt.py +++ b/backend/employment_agent/prompt.py @@ -19,7 +19,8 @@ def prompt(): You are given CV/resume text. Identify the candidate's CURRENT employer company 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: - 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 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): - 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"). @@ -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): 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: {{ "current_employment": "Acme", "education": "BS CS", "current_title": "Engineer", "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: @@ -77,13 +93,23 @@ Example 5 — wrapped LinkedIn slug: Resume: "linkedin.com/in/\\njane-doe-123" 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: {{ "current_employment": "Company Name", "education": "Degree / School", "current_title": "Job Title", "linkedin_url": "https://www.linkedin.com/in/slug", - "phone": "+92 300 1234567" + "phone": "+92 300 1234567", + "skills": ["Skill One", "Skill Two"], + "years_experience": 5 }} """ diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index 2b399ad..a181ff1 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -196,6 +196,11 @@ async def fetch_form_data( search: str | None = Query(None), processing_state: str | 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), # Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged. 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( sheet=sheet,search=search,offset=offset,limit=limit, 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}) except HTTPException: @@ -218,12 +224,21 @@ async def fetch_form_data( @router.get("/sheet/form-data/counts") async def fetch_form_data_counts( 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), session: AsyncSession = Depends(get_session), ): try: 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}) except HTTPException: raise diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index c40ff7e..1be1a5d 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -5,7 +5,7 @@ from __future__ import annotations import uuid 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.ext.asyncio import AsyncSession from sqlmodel import Field, SQLModel, select @@ -17,6 +17,17 @@ def _now() -> datetime: _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): """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)) @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 = [] if sheet: filters.append(cls.sheet == sheet) @@ -107,6 +121,25 @@ class FormData(SQLModel, table=True): filters.append(cls.processing_state == processing_state) if is_duplicate is not None: 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: # 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 @@ -279,12 +312,14 @@ class FormData(SQLModel, table=True): @classmethod async def fetch_form_data( 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) for clause in cls._filters( sheet=sheet, search=search, processing_state=processing_state, is_duplicate=is_duplicate, + has_linkedin=has_linkedin, has_resume=has_resume, ): statement = statement.where(clause) if offset: @@ -336,20 +371,36 @@ class FormData(SQLModel, table=True): @classmethod async def count_form_data( 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) for clause in cls._filters( sheet=sheet, search=search, processing_state=processing_state, is_duplicate=is_duplicate, + has_linkedin=has_linkedin, has_resume=has_resume, ): statement = statement.where(clause) result = await session.execute(statement) return result.scalar_one() @classmethod - async def count_processing(cls, session: AsyncSession, *, sheet=None): - """Tab badge counts for the Sheet Forms channel.""" + async def count_processing( + 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( func.count().label("all"), 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.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712 ).select_from(cls) - if sheet: - statement = statement.where(cls.sheet == sheet) + for clause in cls._filters( + sheet=sheet, search=search, + has_linkedin=has_linkedin, has_resume=has_resume, + ): + statement = statement.where(clause) row = (await session.execute(statement)).one() return { "all": int(row.all or 0), diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index ec32b82..3764182 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -431,16 +431,18 @@ class SheetFormData(Sheet): async def get_form_data( 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() rows=await FormData.fetch_form_data( session,sheet=sheet,search=search,offset=offset,limit=limit, processing_state=processing_state,is_duplicate=is_duplicate, + has_linkedin=has_linkedin,has_resume=has_resume, ) total=await FormData.count_form_data( session,sheet=sheet,search=search, 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]) from job.candidate.views import CandidateView @@ -587,11 +589,17 @@ class SheetFormData(Sheet): raise HTTPException(status_code=404,detail="Form data not found") return await self.get_form_data_by_id(record_id) - async def get_counts(self,sheet=None): - return await FormData.count_processing(self._require_session(),sheet=sheet) + 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,search=search, + has_linkedin=has_linkedin,has_resume=has_resume, + ) - async def count_rows(self,sheet=None): - return await FormData.count_form_data(self._require_session(),sheet=sheet) + 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,search=search, + has_linkedin=has_linkedin,has_resume=has_resume, + ) async def get_imported_sheets(self): session=self._require_session() diff --git a/backend/inbox/models.py b/backend/inbox/models.py index a39c3a7..07b54ba 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -210,6 +210,86 @@ class Inbox(SQLModel, table=True): }) 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 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.""" diff --git a/backend/job/app.py b/backend/job/app.py index 70fee20..caee209 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query,Response from fastapi.responses import FileResponse,JSONResponse from fastapi import HTTPException 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.notes.views import Note from job.activity.views import ActivityLog @@ -25,6 +25,7 @@ from datetime import datetime, time, timezone from pydantic import BaseModel from uuid import UUID from typing import Literal, Optional +import os import uuid load_dotenv() logging.basicConfig(level=logging.INFO) @@ -32,12 +33,25 @@ logger = logging.getLogger(__name__) 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): id: UUID job_post_id: UUID | None = None +class CvBankScoreRequest(BaseModel): + job_id: UUID + ids: list[UUID] + + class CandidateUpdate(BaseModel): favorite: bool | 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 text=parsed.get("text") or "" 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 # 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" @@ -333,7 +350,10 @@ async def cv_bank_upload( file_name=original, created_by=current_user.get("id"), 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: uploaded=S3().upload_for_record( @@ -372,27 +392,84 @@ async def cv_bank_upload( async def cv_bank_fetch( top: int = Query(100, ge=1, le=500), 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)), session: AsyncSession = Depends(get_session), ): - """The stored-CV bank, newest first. Download the file via - GET /documents/download?manual_upload_candidate_id=.""" - from job.candidate.models import Manual_UPLOAD_CANDIDATE + """The CV Bank: speculative uploads plus rejected applicants who scored well. + + `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=.""" try: - rows,total=await Manual_UPLOAD_CANDIDATE.list_bank(session,limit=top,offset=skip) - data=[{ - "id":str(r.id), - "file_name":r.file_name, - "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] + service=CandidateView(session=session) + data,total=await service.list_bank( + source=source,search=search,skills=skills,min_years=min_years, + band=band,job_post_id=job_post_id,limit=top,offset=skip, + ) return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: raise 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)) diff --git a/backend/job/candidate/bank_tasks.py b/backend/job/candidate/bank_tasks.py new file mode 100644 index 0000000..5a07b21 --- /dev/null +++ b/backend/job/candidate/bank_tasks.py @@ -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) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 0ac06bf..f7a2af9 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -1,9 +1,10 @@ import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, List, Optional from fastapi import HTTPException from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_ +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -52,6 +53,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): platform: str = Field(default="") created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") 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. status: str = Field(default="") # 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, created_by, pdf_bytes, 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. 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 Candidates screen; unlike an application there is still no inbox entry, 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 from role.models import EnumRoles, Roles @@ -550,7 +568,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): user.is_active = True 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: linkedin_slug = slug_from_url(url) or NO_SLUG else: @@ -558,13 +577,25 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): if user and 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( candidate_email=email, candidate_name=(candidate_name or "").strip() or (email or ""), + candidate_phone=(extracted.get("candidate_phone") or "").strip(), job_post_id=None, full_text=full_text or "", linkedin_slug=linkedin_slug, 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", user_id=user.id if user else None, created_by=cls._as_uuid(created_by), @@ -603,6 +634,58 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): ) 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 async def list_matching(cls, session: AsyncSession, *, assigned=None, search=None, limit=100, offset=0): @@ -695,6 +778,41 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): await session.refresh(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 async def delete_bank_cv(cls, session: AsyncSession, record_id): """Hard delete unassigned bank rows only — assigned rows are applications. @@ -710,6 +828,60 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): 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): """PDF bytes of a CV-bank entry — in the database so production redeploys (ephemeral container filesystems) can never lose a stored CV. Created in diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index e79371d..92212d1 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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]: return { "id":str(row.id) if row.id else None, diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 92c9b2d..023e8ce 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -209,6 +209,101 @@ async def parse_linkedin_url_from_cv(resume_text) -> str | None: logger.exception("employment agent linkedin_url parse failed") 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 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: def __init__(self,session:AsyncSession,filename=None,file=None): self.session=session @@ -524,6 +619,60 @@ class CandidateScoring: 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) + 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): # job_id omitted -> the whole pool across jobs (frontend Candidates/TalentPool). if job_id is not None: @@ -1319,6 +1468,89 @@ class CandidateView: name=(entry.get("name") or path.name).strip() or 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): rows,total=await Manual_UPLOAD_CANDIDATE.list_matching( self.session,assigned=assigned,search=search,limit=limit,offset=offset, diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index de109e5..bd28518 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -191,6 +191,12 @@ class JobPost: if rec: 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: return serialize_job_post(row) @@ -217,6 +223,25 @@ class JobPost: ) 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): try: return await list_buffer_channels() diff --git a/backend/matching/__init__.py b/backend/matching/__init__.py new file mode 100644 index 0000000..70702ef --- /dev/null +++ b/backend/matching/__init__.py @@ -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. +""" diff --git a/backend/matching/ranking.py b/backend/matching/ranking.py new file mode 100644 index 0000000..fb5c795 --- /dev/null +++ b/backend/matching/ranking.py @@ -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)) diff --git a/backend/migrations/manual/029_cv_bank_profile.sql b/backend/migrations/manual/029_cv_bank_profile.sql new file mode 100644 index 0000000..57cd437 --- /dev/null +++ b/backend/migrations/manual/029_cv_bank_profile.sql @@ -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), '') = ''; diff --git a/backend/migrations/manual/030_cv_bank_matches.sql b/backend/migrations/manual/030_cv_bank_matches.sql new file mode 100644 index 0000000..956779b --- /dev/null +++ b/backend/migrations/manual/030_cv_bank_matches.sql @@ -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); diff --git a/backend/talent/plugins.py b/backend/talent/plugins.py index 89c6229..a90ffa3 100644 --- a/backend/talent/plugins.py +++ b/backend/talent/plugins.py @@ -13,12 +13,13 @@ normalize_profile. from __future__ import annotations import os -import re from urllib.parse import urlsplit import httpx from dotenv import load_dotenv +from matching.ranking import rank_profile + load_dotenv() # 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") -_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", -} - - -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) +# Moved to matching/ranking.py so the CV Bank ranks stored resumes with the +# same arithmetic instead of growing a second copy that drifts. Re-exported +# under the original name: every call site here and in talent/views.py is +# unchanged, and the numbers this produces are identical. +relevance_score = rank_profile def _date_text(value) -> str | None: diff --git a/backend/taskiq_management/broker_setup.py b/backend/taskiq_management/broker_setup.py index 931634c..318665f 100644 --- a/backend/taskiq_management/broker_setup.py +++ b/backend/taskiq_management/broker_setup.py @@ -1,6 +1,6 @@ """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 """ diff --git a/backend/tests/test_analytics_dashboard.py b/backend/tests/test_analytics_dashboard.py new file mode 100644 index 0000000..ca85489 --- /dev/null +++ b/backend/tests/test_analytics_dashboard.py @@ -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 diff --git a/backend/tests/test_cv_bank_ranking.py b/backend/tests/test_cv_bank_ranking.py new file mode 100644 index 0000000..90b980a --- /dev/null +++ b/backend/tests/test_cv_bank_ranking.py @@ -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 diff --git a/backend/tests/test_employment_agent.py b/backend/tests/test_employment_agent.py index 56be7aa..4c33cf4 100644 --- a/backend/tests/test_employment_agent.py +++ b/backend/tests/test_employment_agent.py @@ -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 @@ -7,7 +12,26 @@ from employment_agent.prompt import EDUCATION, NO_COMPANY, NO_LINKEDIN 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", "education": "BS CS", @@ -16,14 +40,11 @@ def test_parses_linkedin_url_key_separately(): }, "Acme BS CS Engineer", ) - assert company == "Acme" - assert education == "BS CS" - assert title == "Engineer" - assert url == "https://www.linkedin.com/in/jane-doe" + assert fields["linkedin_url"] is None def test_sentinel_and_non_linkedin_are_dropped(): - *_, url = parse_employment_response( + sentinel = parse_employment_response( { "current_employment": NO_COMPANY, "education": EDUCATION, @@ -32,8 +53,9 @@ def test_sentinel_and_non_linkedin_are_dropped(): }, "", ) - assert url is None - *_, github = parse_employment_response( + assert sentinel["linkedin_url"] is None + + github = parse_employment_response( { "current_employment": NO_COMPANY, "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(): - *_, url = parse_employment_response( + bare = parse_employment_response( { "current_employment": NO_COMPANY, "education": EDUCATION, @@ -55,8 +77,9 @@ def test_adds_scheme_and_rejects_company_page(): }, "", ) - assert url == "https://www.linkedin.com/in/jane-doe" - *_, company = parse_employment_response( + assert bare["linkedin_url"] == "https://www.linkedin.com/in/jane-doe" + + company_page = parse_employment_response( { "current_employment": NO_COMPANY, "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 diff --git a/backend/tests/test_employment_extraction_clamps.py b/backend/tests/test_employment_extraction_clamps.py new file mode 100644 index 0000000..d4bdacd --- /dev/null +++ b/backend/tests/test_employment_extraction_clamps.py @@ -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"] diff --git a/backend/tests/test_form_data_filters.py b/backend/tests/test_form_data_filters.py new file mode 100644 index 0000000..9eef6e3 --- /dev/null +++ b/backend/tests/test_form_data_filters.py @@ -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 diff --git a/backend/tests/test_manager_candidate_serialize.py b/backend/tests/test_manager_candidate_serialize.py new file mode 100644 index 0000000..60edf96 --- /dev/null +++ b/backend/tests/test_manager_candidate_serialize.py @@ -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 diff --git a/frontend/cvbank.test.mjs b/frontend/cvbank.test.mjs new file mode 100644 index 0000000..9c676bf --- /dev/null +++ b/frontend/cvbank.test.mjs @@ -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') diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 9fdb59b..b6f287a 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -24,8 +24,8 @@ - - + +
diff --git a/frontend/inbox-loading.test.mjs b/frontend/inbox-loading.test.mjs index 18f276d..e68b450 100644 --- a/frontend/inbox-loading.test.mjs +++ b/frontend/inbox-loading.test.mjs @@ -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 = [ { 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 } +/** 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) => { const url = String(input?.url ?? input) + REQUESTS.push(url) let body 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/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/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 body = { data: [], status_code: 200 } return { @@ -297,6 +309,97 @@ try { `calls before=${firstVisitEmailCalls} after=${emailGate.calls}`, ) 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 { rmSync(outDir, { recursive: true, force: true }) } diff --git a/frontend/mobile.test.mjs b/frontend/mobile.test.mjs index 78bc00a..b9d6d8f 100644 --- a/frontend/mobile.test.mjs +++ b/frontend/mobile.test.mjs @@ -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). const ROUTES = [ - 'dashboard', 'inbox', 'matching', 'jobs', 'candidates', 'talentpool', 'pipeline', + 'dashboard', 'inbox', 'matching', 'jobs', 'candidates', 'cvbank', 'pipeline', 'progress', 'import', 'jobboard', 'recruiterhub', 'talent', 'tasks', 'aiassistant', 'interviews', 'requisitions', 'assessments', 'offers', 'managers', 'calendar', 'reports', 'analytics', 'aistudio', 'notifications', 'rbac', 'settings', 'help', diff --git a/frontend/package.json b/frontend/package.json index c3763f3..c3b0f70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,8 +12,10 @@ "test:token": "node token.test.mjs", "test:theme": "node theme.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", - "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": { "@tanstack/react-query": "^5.101.4", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 621f0d6..9c52297 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,7 +20,7 @@ const SCREENS = { matching: lazy(() => import('./screens/Matching')), jobs: lazy(() => import('./screens/Jobs')), candidates: lazy(() => import('./screens/Candidates')), - talentpool: lazy(() => import('./screens/TalentPool')), + cvbank: lazy(() => import('./screens/CvBank')), pipeline: lazy(() => import('./screens/Pipeline')), progress: lazy(() => import('./screens/Progress')), import: lazy(() => import('./screens/CvImport')), diff --git a/frontend/src/__smoke__/entry.jsx b/frontend/src/__smoke__/entry.jsx index aa7018f..e6287d4 100644 --- a/frontend/src/__smoke__/entry.jsx +++ b/frontend/src/__smoke__/entry.jsx @@ -28,7 +28,7 @@ import Inbox from '../screens/Inbox' import Matching from '../screens/Matching' import Jobs from '../screens/Jobs' import Candidates from '../screens/Candidates' -import TalentPool from '../screens/TalentPool' +import CvBank from '../screens/CvBank' import Pipeline from '../screens/Pipeline' import Progress from '../screens/Progress' import CvImport from '../screens/CvImport' @@ -53,7 +53,7 @@ import Help from '../screens/Help' const SCREENS = { 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, interviews: Interviews, requisitions: Requisitions, assessments: Assessments, offers: Offers, 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 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 { settle, + click, + selectOption, html: () => container.innerHTML, 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() }) }, } } diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 4b9acec..92d83ec 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -55,9 +55,47 @@ export function uploadToCvBank(file) { 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 } = {}) { - return request('/candidate/cv-bank/fetch', { params: { top, skip } }) +/** + * The CV Bank — GET /candidate/cv-bank/fetch. Two populations in one list: + * 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. */ @@ -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 } = {}) { return request('/candidate/fetch', { params: { search, limit, offset, assigned_job_post_id: assignedJobPostId }, diff --git a/frontend/src/api/sheet.js b/frontend/src/api/sheet.js index 99d18a0..29a436f 100644 --- a/frontend/src/api/sheet.js +++ b/frontend/src/api/sheet.js @@ -17,12 +17,20 @@ export function listFormDataSheets() { * * `offset` / `limit` map 1:1 to the backend Query params (not skip/top). * 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({ sheet, search, offset = 0, limit, processing_state, is_duplicate, + hasLinkedin, hasResume, } = {}) { 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 } }) } -/** Tab badge counts for one sheet (or all sheets when sheet omitted). */ -export function fetchFormCounts({ sheet } = {}) { - return request('/sheet/form-data/counts', { params: { sheet } }) +/** + * Tab badge counts for one sheet (or all sheets when sheet omitted). + * + * 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. */ diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index b1c52b4..6ad2ad0 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -21,7 +21,12 @@ export const ROUTES = [ { 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: '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: 'progress', title: 'Progress', icon: 'trending-up', group: 'Workspace', permission: 'jobs.view' }, diff --git a/frontend/src/auth/permissions.js b/frontend/src/auth/permissions.js index c42fe03..fb454bf 100644 --- a/frontend/src/auth/permissions.js +++ b/frontend/src/auth/permissions.js @@ -37,7 +37,7 @@ export function makeCan(permissions) { 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. */ export const HIRING_MANAGER_NAV = new Set([ 'candidates', 'requisitions', 'interviews', 'calendar', diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index b906e0f..5a9ccbd 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -45,7 +45,8 @@ export const qk = { }, cvBank: { all: () => ['cvBank'], - list: () => ['cvBank', 'list'], + list: (p = {}) => ['cvBank', 'list', p], + suggestions: (jobId) => ['cvBank', 'suggestions', jobId], }, notifications: { all: () => ['notifications'], diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index f2fe9de..35a563e 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -788,9 +788,7 @@ function fmtStamp(value) { * (backend/job/candidate/views.py:782-792). * * The prop is the last fallback, for callers whose rows already carry a score - * (Talent Pool cards, the scored leaderboard). - * - * Exported so TalentPool's profile modal can open the same ATS breakdown. + * (the scored leaderboard). */ export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) { const userId = c.userId ?? null diff --git a/frontend/src/screens/CvBank.jsx b/frontend/src/screens/CvBank.jsx new file mode 100644 index 0000000..70cf1bf --- /dev/null +++ b/frontend/src/screens/CvBank.jsx @@ -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 Pick a job + if (rank == null) return + return ( +
+
{rank}/100
+ + ) +} + +function AtsCell({ score, recommendation }) { + if (score == null) return Not scored + return ( +
+ + {recommendation && ( +
+ {recommendation} +
+ )} +
+ ) +} + +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=") 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 ( +
+ {total} CV{total === 1 ? '' : 's'} held for future roles{selectedJob ? <> · ranked against {selectedJob.title} : null} + : 'CVs held for future roles' + } + actions={<> + + + } + /> + +
+
+
+
+ + setQ(e.target.value)} + placeholder="Search name, email, company, or skill…" + /> +
+ +
+ {/* The "a role just opened, who do we already have" control. This is + the moment the bank is meant to be used. */} +
+ + +
+
+ + {showFilters && ( +
+ setFilter('source', v)} + any="Any source" + options={SOURCE_FILTERS} + labels={SOURCE_LABELS} + /> + setFilter('band', v)} + any="Any band" + options={BAND_FILTERS} + /> + setFilter('years', v)} + any="Any experience" + options={YEARS_FILTERS} + labels={Object.fromEntries(YEARS_FILTERS.map((y) => [y, `${y}+ years`]))} + /> +
+ )} +
+ + {bankQuery.isPending && ( +
+ )} + {bankQuery.isError && ( +
+ + {friendlyAuthError(bankQuery.error, 'Request failed')} + +
+ )} + + {bankQuery.isSuccess && ( +
+
+ + + + {t.pageRows.length === 0 ? ( + + + + ) : ( + t.pageRows.map((r) => ( + + + + + + + + + + + + )) + )} + +
+ {search || filters.source || filters.band || filters.years ? ( + + No held CV matches these filters. Try widening them. + + ) : ( + + Import CVs with “No job — store in CV bank” selected, and + rejected applicants who scored well will show up here too. + + )} +
+
+ +
+
{r.name}
+
{r.email || r.fileName || 'No email detected'}
+
+
+
+ {r.sourceLabel} + {r.lastJobTitle && ( +
+ applied for {r.lastJobTitle} +
+ )} + {r.expiresAt && ( +
{candidatesApi.expiryLabel(r.expiresAt)}
+ )} +
+
{r.title || '—'}
+ {r.company &&
{r.company}
} +
+ {r.years == null ? '—' : r.years} + + {r.skills.length ? ( +
+ {r.skills.slice(0, SKILL_CHIPS).map((s) => ( + {s} + ))} + {r.skills.length > SKILL_CHIPS && ( + + +{r.skills.length - SKILL_CHIPS} + + )} +
+ ) : ( + None extracted + )} +
+ {r.added ? r.added.toLocaleDateString() : '—'} + +
+ {r.isStoredCv && } + + {r.isStoredCv && (<> + + + + + )} +
+
+
+ setSkip((p - 1) * pageSize)} + pageButtons={pageWindow(currentPage, pages)} + pageSize={pageSize} + onPageSizeChange={(n) => setPageSize(n)} + pageSizeMax={500} + /> +
+ )} +
+ +

+ Match is free keyword overlap against the selected + job — it orders this list, it does not assess anyone. ATS is a real + scored result and only appears once someone runs one. +

+ + {scoreFor && ( + setScoreFor(null)} + onConfirm={(jobId) => scoring.mutate({ jobId, ids: [scoreFor.recordId] })} + /> + )} + + {assignFor && ( + setAssignFor(null)} + onConfirm={(jobId) => assigning.mutate({ id: assignFor.recordId, jobId })} + /> + )} + + {preview && ( + Close} + > + {/* Blob URL re-typed to application/pdf so the browser's built-in + viewer renders inline instead of triggering a download. */} +