Skip to content

Mr. Hire — AI Resume Analysis & Screening

This document describes the AI-assisted resume analysis and candidate screening domain of the Mr. Hire recruitment product inside mr-mentor-backend. It covers how a job application's resume is pulled from S3, sent to the mr-hire-backend AI service for parsing and scoring, gated through a configurable multi-stage pipeline (resume → quiz → AI voice call → final score), and surfaced in the ATS. It also covers the standalone student "resume review" token-spend flow and the AI-screening configuration entities that drive automation.

Status: documented from source on this branch.


Overview

The screening domain turns an inbound JobApplication (with an uploaded resume on S3) into a scored, tiered, and optionally shortlisted candidate — with as little human work as the recruiter configures. There are three distinct sub-domains living under this umbrella:

  1. AI screening pipeline (the live system). Driven by the resumeAnalysisQueue BullMQ queue and src/workers/resumeAnalysis.worker.ts. Each job carries a JobApplication id and a type (resume-analysis / full-screening / ai-call / ai-call-check). The worker fetches the resume from S3, calls mr-hire-backend for parsing + scoring + quiz curation, writes everything to a ScreeningResult row, gates the candidate through configurable thresholds, and computes a weighted final score. Results feed the ATS, the talent pool, and the workflow-automation engine.

  2. Legacy basic resume analysis. ResumeAnalysisService + the ResumeAnalysis entity provide a simpler "Tier A/B/C" analysis (calls mr-hire-backend /api/evaluate). Its read endpoints are still mounted, but its write path (processResumeAnalysis) is not wired into the queue worker any more — see Edge cases & gotchas.

  3. Student resume review (token spend). ResumeReviewService lets a paid student spend one resumeToken to request a human resume review. Fulfilment is off-system (email ops) in v1.

Personas / roles

Persona Role enum What they do here
Recruiter / HR admin ADMIN, EXTERNAL_HR Trigger screening, view analyses, retry stages, tune per-job & global config
Super admin ADMIN Manage system-wide AiScreeningConfig (auto-screen on apply)
Student USER Spend a resume-review token, list their review history
Candidate (external) none (public) Uploads a resume via a hiring form; screening runs automatically
Backend-to-backend service token mr-hire-backend calls back (call webhooks) and is called by the worker

This domain sits between Job Posts & Applications (the ATS intake) and the downstream stages — Candidate Quizzes, Voice Interviews, and the Talent Pool. The actual LLM work is performed by mr-hire-backend; this repo orchestrates and persists results.


Key concepts & entities

Glossary

  • Screening pipeline — the ordered stages resume → quiz → AI call → final score. Each stage is gated by config thresholds; failing a gate short-circuits to final scoring.
  • Tier — coarse resume bucket: Tier A / Tier B / Tier C, returned by the AI service.
  • Gate — a config-driven threshold check (e.g. resume score below minResumeScore ⇒ auto-reject, skip quiz and call).
  • Final score — weighted blend of resume / quiz / voice-interview scores, with weights re-normalized over only the stages that actually completed.
  • Config fallback chain — job-specific config → user-global → system-global → hardcoded defaults.
  • Name verification — the worker compares the resume's parsed name with the applied name; a mismatch deletes the application and emails the candidate + HR.

Entities (TypeORM)

Entity Table File Role
ScreeningResult screening_results src/entities/ScreeningResult.entity.ts The live pipeline record — resume/quiz/call scores, parsed data, status, cost, config snapshot
ResumeAnalysis resume_analyses src/entities/ResumeAnalysis.entity.ts Legacy Tier A/B/C analysis (read-only in practice)
JobScreeningConfig job_screening_configs src/entities/JobScreeningConfig.entity.ts Per-job or global thresholds, weights, automation rules
AiScreeningConfig ai_screening_configs src/entities/AiScreeningConfig.entity.ts Per-HR-user auto-screen on/off toggles
ResumeReviewRequest resume_review_requests src/entities/ResumeReviewRequest.ts Student token-spend resume review request
JobApplication job_applications src/entities/JobApplication.ts The candidate record screened; owns CandidateStatus

Related entities written by the worker but owned by sibling domains: TalentPoolEntry, WorkflowRule, EmailTemplate, CandidateQuizResponse, PipelineCostLedger.


Architecture

flowchart TD
  subgraph Clients["Clients"]
    HR["HR / Admin UI (mr-mentor-frontend)"]
    Form["Public hiring form / bulk import"]
    Student["Student dashboard"]
  end

  subgraph Routes["Express routes"]
    RA["resumeAnalysis.routes (/api/admin)"]
    AISC["aiScreeningConfig.routes (/api/admin)"]
    JSC["jobPost.routes (/api/hr)"]
    TG["tokenGovernance.routes (/api/resume-reviews)"]
    MRH["mrHire.controller intake"]
  end

  subgraph Controllers["Controllers"]
    RAC["ResumeAnalysisController"]
    AISCC["AiScreeningConfigController"]
    JSCC["JobScreeningConfigController"]
    RRC["ResumeReviewController"]
  end

  subgraph Services["Services"]
    RAS["ResumeAnalysisService (legacy)"]
    RRS["ResumeReviewService"]
    QS["QueueService"]
    S3["S3Service"]
  end

  subgraph Async["Async layer"]
    Q["resumeAnalysisQueue (BullMQ)"]
    W["resumeAnalysis.worker (concurrency 5)"]
  end

  subgraph DB["PostgreSQL"]
    SR[("screening_results")]
    JSCT[("job_screening_configs")]
    AISCT[("ai_screening_configs")]
    RRT[("resume_review_requests")]
    RAT[("resume_analyses")]
  end

  subgraph External["External systems"]
    MH["mr-hire-backend (LLM screening, quiz, call)"]
    S3B["AWS S3 (resume files)"]
    Email["emailQueue (Nodemailer)"]
  end

  HR --> RA --> RAC --> QS --> Q --> W
  HR --> JSC --> JSCC --> JSCT
  HR --> AISC --> AISCC --> AISCT
  Form --> MRH --> QS
  Student --> TG --> RRC --> RRS --> RRT

  W --> S3 --> S3B
  W -->|"single screening, analyze-call"| MH
  W --> SR
  W --> JSCT
  W --> Email
  RAC --> RAS --> RAT
  RAS -->|"legacy /api/evaluate"| MH

Data model

erDiagram
  JOB_APPLICATION ||--o{ SCREENING_RESULT : "screened by"
  JOB_APPLICATION ||--o{ RESUME_ANALYSIS : "legacy analysis"
  JOB_POST ||--o| JOB_SCREENING_CONFIG : "per-job config"
  USER ||--o| AI_SCREENING_CONFIG : "auto-screen prefs"
  USER ||--o{ RESUME_REVIEW_REQUEST : "requests"

  SCREENING_RESULT {
    uuid id PK
    uuid application_id FK
    float overallResumeScore
    float similarityScore
    string tier
    boolean shortlisted
    jsonb parsedSkills
    int parsedExperienceMonths
    jsonb quizQuestions
    string quizLink
    float quizScore
    string callId
    string callStatus
    int callAttempts
    float communicationScore
    string nameMatchStatus
    float finalScore
    string finalRecommendation
    float totalCostUsd
    jsonb screeningConfig
    string screeningStatus
    jsonb rawResponse
  }

  RESUME_ANALYSIS {
    uuid id PK
    uuid application_id FK
    jsonb extractedInfo
    string tier
    text summary
    jsonb scores
    string status
    text errorMessage
  }

  JOB_SCREENING_CONFIG {
    uuid id PK
    uuid jobPostId FK
    boolean isGlobal
    uuid belongsTo
    int weightResume
    int weightQuiz
    int weightVoiceInterview
    int minResumeScore
    int minQuizScore
    int minInterviewScore
    int autoShortlistThreshold
    int autoRejectThreshold
    boolean aiCallEnabled
    boolean quizAutoInvite
    boolean requireAllStages
    boolean whatsappNotificationEnabled
  }

  AI_SCREENING_CONFIG {
    uuid id PK
    uuid userId UK
    boolean autoScreenEnabled
    boolean autoScreenOnApply
  }

  RESUME_REVIEW_REQUEST {
    uuid id PK
    uuid studentId FK
    text resumeUrl
    text notes
    string status
    uuid reviewerId FK
    text reviewFileUrl
    timestamp reviewedAt
  }

Notable enums / status fields

  • ScreeningResult.screeningStatus (ScreeningStatus): pending | resume_screening | resume_screened | quiz_sent | quiz_completed | call_scheduled | call_completed | completed | failed.
  • ScreeningResult.callStatus (free string): in_progress | ended | skipped | failed | retry_scheduled.
  • ScreeningResult.nameMatchStatus: matched | mismatched | not_found | null.
  • ResumeAnalysis.status: pending | processing | completed | failed.
  • ResumeAnalysis.tier: Tier A | Tier B | Tier C.
  • ResumeReviewRequestStatus: pending | under_review | completed | rejected.
  • JobApplication.status (CandidateStatus): resume_receivedresume_parsedquiz_invitedquiz_completedvoice_interview_scheduledvoice_interview_completedscoring_completeshortlisted / rejected / talent_pool.

API surface

All paths below are the real mounted paths. resumeAnalysis.routes and aiScreeningConfig.routes are both mounted under /api/admin; jobPost.routes and tokenGovernance.routes under /api.

Resume analysis & screening pipeline — mounted at /api/admin

Method Path Auth/role Purpose
POST /api/admin/applications/:applicationId/analyze authMiddleware + ADMIN or EXTERNAL_HR Enqueue a resume-analysis job. Skips if a completed analysis already exists
POST /api/admin/applications/:applicationId/screen ADMIN or EXTERNAL_HR Enqueue a full-screening job (resume → quiz → call)
POST /api/admin/applications/:applicationId/retry-stage ADMIN or EXTERNAL_HR Retry one stage. Body stage = ai-call (resets call fields + enqueues) or quiz
GET /api/admin/applications/:applicationId/analysis ADMIN or EXTERNAL_HR Latest ResumeAnalysis for an application
GET /api/admin/analysis/:analysisId ADMIN or EXTERNAL_HR Fetch one analysis by id
GET /api/admin/analysis ADMIN or EXTERNAL_HR List analyses, ?limit/?offset
GET /api/admin/analysis/tier/:tier ADMIN or EXTERNAL_HR List analyses by tier
GET /api/admin/analysis/statistics ADMIN or EXTERNAL_HR Counts by tier and status

Note: GET /analysis/statistics is registered after GET /analysis/:analysisId; the :analysisId route does not match statistics because /analysis/statistics is a distinct two-segment path matched by the explicit route. The list route /analysis is separate.

AI screening config — mounted at /api/admin

Method Path Auth/role Purpose
GET /api/admin/mr-hire/ai-screening-configs authMiddleware + adminMiddleware List all per-user auto-screen configs
POST /api/admin/mr-hire/ai-screening-config authMiddleware + adminMiddleware Upsert { userId, autoScreenEnabled, autoScreenOnApply }

Job screening config (thresholds & weights) — mounted at /api

Method Path Auth/role Purpose
GET /api/hr/screening-config/global authMiddleware Get caller's global config (falls back user → system → hardcoded)
PUT /api/hr/screening-config/global authMiddleware Upsert caller's global config (weights must sum to 100)
GET /api/hr/job-posts/:jobPostId/screening-config authMiddleware Get per-job config with fallback chain
PUT /api/hr/job-posts/:jobPostId/screening-config authMiddleware Upsert per-job config (weights must sum to 100)

Student resume review (token spend) — mounted at /api

Method Path Auth/role Purpose
POST /api/resume-reviews authMiddleware Spend 1 resume token, create a review request. Gated by ENABLE_RESUME_REVIEW_SPEND
GET /api/resume-reviews authMiddleware List the caller's own review history

User journeys

Journey 1 — Auto-screening on application submit (happy path)

When a candidate applies (public hiring form or bulk import), the intake controller may enqueue a resume-analysis job automatically depending on AiScreeningConfig.autoScreenOnApply, a global system flag, or a special source. The worker runs the resume stage, curates a quiz, and emails the candidate a quiz link.

sequenceDiagram
  participant Cand as Candidate
  participant MRH as mrHire.controller
  participant QS as QueueService
  participant Q as resumeAnalysisQueue
  participant W as resumeAnalysis.worker
  participant S3 as S3Service
  participant MH as mr-hire-backend
  participant DB as Postgres
  participant EM as emailQueue

  Cand->>MRH: submit application with resume file
  MRH->>DB: save JobApplication status resume_received
  MRH->>QS: addResumeAnalysisJob type resume-analysis
  QS->>Q: enqueue with priority
  Q->>W: deliver job
  W->>DB: load application and resolve screening config
  Note over W: fallback chain job then global then default
  W->>DB: upsert ScreeningResult status resume_screening with config snapshot
  W->>S3: get signed resume URL
  W->>MH: POST screening single with resumeUrl and JD and enableQuiz
  MH-->>W: parsed data and resume_score and tier and quiz
  W->>W: name verification of resume name vs applied name
  W->>DB: save scores and parsed data and quiz status resume_screened
  W->>DB: record parsing and embedding cost in pipeline ledger
  alt resume score below minResumeScore
    W->>DB: final score NOT QUALIFIED and app status rejected
    W->>EM: queue regret email
  else passed and quizAutoInvite true
    W->>EM: queue quiz invitation email
    W->>DB: app status quiz_invited
  else passed and aiCallEnabled false
    W->>W: compute final score from resume and quiz
    W->>DB: auto shortlist or reject or scoring_complete
  end

Journey 2 — HR manually triggers full screening

An HR user clicks "Screen" in the ATS. The route enqueues a full-screening job, which the worker treats identically to resume-analysis (Stage 1). If a prior ScreeningResult exists, the worker resets every score field and invalidates old quiz responses before re-running.

sequenceDiagram
  participant HR as HR Admin
  participant API as resumeAnalysis.routes
  participant QS as QueueService
  participant W as resumeAnalysis.worker
  participant DB as Postgres
  participant MH as mr-hire-backend

  HR->>API: POST applications id screen
  API->>API: authMiddleware then adminOrHr check
  API->>QS: addResumeAnalysisJob type full-screening with authToken
  QS-->>HR: queued response
  W->>DB: find existing ScreeningResult
  Note over W: reset all prior scores and expire old quiz responses
  W->>DB: delete old CandidateQuizResponse rows
  W->>MH: POST screening single
  MH-->>W: fresh parsed data and scores and quiz
  W->>DB: save ScreeningResult status resume_screened

Journey 3 — Quiz completion triggers the AI voice call

The quiz itself lives in the Candidate Quiz domain. When a candidate submits the quiz and scores above aiCallMinimumScore, that controller enqueues an ai-call job. The worker dials via mr-hire-backend, quick-polls for up to 3 minutes, and either finalizes inline or schedules a delayed ai-call-check.

sequenceDiagram
  participant Cand as Candidate
  participant CQ as candidateQuiz.controller
  participant QS as QueueService
  participant W as resumeAnalysis.worker
  participant DB as Postgres
  participant MH as mr-hire-backend

  Cand->>CQ: submit quiz answers
  CQ->>DB: save quizScore on ScreeningResult
  alt quiz percent at least aiCallMinimumScore and aiCallEnabled
    CQ->>QS: addResumeAnalysisJob type ai-call with delay
  else below threshold
    CQ->>W: compute final score without call
  end
  W->>DB: guard skip if call already processed or in progress
  W->>DB: check gates quiz score and min quiz score
  W->>DB: set callStatus in_progress and app status voice_interview_scheduled
  W->>MH: POST call with phone and prompt
  MH-->>W: callId
  loop quick poll 3 times 60s apart
    W->>MH: GET call by id
    MH-->>W: status in_progress or ended
  end
  alt call ended in window
    W->>MH: POST screening analyze-call with transcript
    MH-->>W: communication and technical and confidence scores
    W->>W: compute weighted final score
    W->>DB: status completed and auto shortlist or reject
  else still running
    W->>QS: addResumeAnalysisJob type ai-call-check delay 5 min
  end

Journey 4 — Delayed call check and webhook finalization

If the call is still running after the quick poll, a delayed ai-call-check job re-fetches the result (up to 3 checks, 5 min apart). Independently, mr-hire-backend may push a call-completion webhook to voiceInterview.controller, which processes scoring directly — both paths guard on callProcessedAt to avoid double-counting.

sequenceDiagram
  participant W as resumeAnalysis.worker
  participant MH as mr-hire-backend
  participant VC as voiceInterview.controller
  participant DB as Postgres

  Note over W: ai-call-check job fires after delay
  W->>DB: load ScreeningResult and skip if callProcessedAt set
  W->>MH: GET call by id
  MH-->>W: status ended with transcript
  alt ended with transcript
    W->>MH: POST analyze-call
    MH-->>W: scores
    W->>DB: set callProcessedAt and compute final score
  else not ended and checks under 3
    W->>W: schedule next ai-call-check
  else not ended after 3 checks
    W->>DB: callStatus failed and status call_completed
  end

  Note over VC: alternate path - provider webhook arrives first
  MH->>VC: call completion webhook
  VC->>DB: process scoring and set callProcessedAt
  VC->>W: on failure enqueue ai-call retry with backoff

Journey 5 — Name mismatch short-circuit

During Stage 1 the worker compares the resume's parsed name against the name on the application (with filename hinting and token-overlap heuristics). On mismatch it emails the candidate and HR, marks screening failed, and deletes the application.

sequenceDiagram
  participant W as resumeAnalysis.worker
  participant MH as mr-hire-backend
  participant DB as Postgres
  participant EM as emailQueue

  W->>MH: POST screening single
  MH-->>W: parsed data and resume_name and maybe name_mismatch flag
  W->>W: normalise names and compare with token overlap
  alt names match or contain each other
    W->>DB: nameMatchStatus matched and continue pipeline
  else mismatch
    W->>EM: queue mismatch email to candidate
    W->>EM: queue alert email to HR owner
    W->>DB: ScreeningResult status failed with reason
    W->>DB: delete JobApplication
    Note over W: worker returns reason name_mismatch
  end

Journey 6 — Student spends a resume-review token

A separate, synchronous flow. A paid student requests a human resume review; the service spends one resumeToken transactionally (promotional bucket first, then wallet) and writes a TokenUsage ledger row. Reviewer fulfilment is off-system in v1.

sequenceDiagram
  participant St as Student
  participant API as tokenGovernance.routes
  participant RRC as ResumeReviewController
  participant RRS as ResumeReviewService
  participant DB as Postgres

  St->>API: POST resume-reviews with resumeUrl and notes
  API->>RRC: authMiddleware then handler
  RRC->>RRC: check ENABLE_RESUME_REVIEW_SPEND flag
  alt flag disabled
    RRC-->>St: 503 not enabled
  else enabled
    RRC->>RRS: createRequest
    RRS->>DB: begin transaction with pessimistic lock
    alt promotional resumeToken at least 1
      RRS->>DB: decrement promo resumeToken
    else wallet resumeToken at least 1
      RRS->>DB: decrement wallet resumeToken
    else no tokens
      RRS-->>RRC: throw insufficient tokens
      RRC-->>St: 400 insufficient tokens
    end
    RRS->>DB: insert ResumeReviewRequest pending
    RRS->>DB: insert TokenUsage RESUME_REVIEW ledger row
    RRS->>DB: commit
    RRS-->>St: 201 request submitted
  end

Journey 7 — Configuring screening thresholds

sequenceDiagram
  participant HR as HR Admin
  participant API as jobPost.routes
  participant JSCC as JobScreeningConfigController
  participant DB as Postgres

  HR->>API: PUT hr job-posts id screening-config with weights and thresholds
  API->>JSCC: authMiddleware then upsertConfig
  JSCC->>JSCC: validate weights sum to 100
  alt weights not 100
    JSCC-->>HR: 400 weights must sum to 100
  else valid
    JSCC->>DB: upsert JobScreeningConfig for jobPostId with belongsTo
    JSCC-->>HR: saved config
  end
  Note over HR,DB: next screening job reads this via fallback chain

Background jobs & async

Queue: resumeAnalysisQueue (defined in src/services/QueueService.ts). defaultJobOptions: removeOnComplete: 100, removeOnFail: 100. Jobs are added with name analyzeResume via QueueService.addResumeAnalysisJob(data, options).

Worker: src/workers/resumeAnalysis.worker.ts, registered at startup by await import('./workers/resumeAnalysis.worker') in src/index.ts. Runtime settings: concurrency: 5, lockDuration: 600000 ms (10 min — long enough to poll a call), lockRenewTime: 60000 ms.

Job types (ResumeAnalysisJobData.type)

Type Stage Behaviour
resume-analysis Stage 1 Resume parse + score + quiz curation, name verification, gating
full-screening Stage 1 Identical to resume-analysis (alias)
ai-call Stage 2 Dial candidate via mr-hire-backend, quick-poll, analyze transcript
ai-call-check Stage 2.5 Delayed re-check of a still-running call (max 3 checks, 5 min apart)

ai-call / ai-call-check are cast as any at enqueue sites — they are valid worker types but are not in the published ResumeAnalysisJobData union in src/types/ResumeQueue.types.ts.

Enqueue sites

  • mrHire.controller.ts — auto-screen on apply, and on bulk import.
  • candidateQuiz.controller.tsai-call after quiz completion + threshold pass.
  • voiceInterview.controller.tsai-call retry on webhook call failure (business-hours-adjusted delay).
  • resumeAnalysis.routes.tsfull-screening on manual screen, ai-call on retry-stage.
  • The worker itself re-enqueues ai-call (business hours, currently disabled) and ai-call-check.

Emails are produced by enqueueing onto emailQueue (type values include quiz-invitation, workflow-email) — see Email & notifications.

Cost tracking: the worker records resume parsing (OpenAI gpt-4.1-mini), embedding (text-embedding-3-small), and quiz-generation (Groq, free) costs into pipeline_cost_ledger via PipelineCostService. Failures here are swallowed and never block screening.

Webhooks: mr-hire-backend posts call-completion events to voiceInterview.controller (see Voice Interviews); both the webhook and the worker guard on callProcessedAt.


External integrations

Integration How used Env var(s) Failure / fallback
mr-hire-backend AI service POST /api/v1/screening/single, POST /api/v1/screening/analyze-call, POST /call, GET /call/:id; legacy POST /api/evaluate MR_HIRE_BACKEND_URL (default http://localhost:8001) Errors mark ScreeningResult.screeningStatus = failed, app reverts to resume_received, job throws (BullMQ retry per options)
Backend-to-backend auth Authorization: Bearer <token> on screening / analyze-call calls INTERNAL_SERVICE_TOKEN If unset, a warning is logged and mr-hire-backend may 401
AWS S3 S3Service.getJobApplicationResumeSignedUrl(resumeKey); legacy path streams the object AWS_*, AWS_S3_* Missing resumeKey throws "No resume file"
Resume-review feature flag FeatureFlags.resumeReviewSpend() ENABLE_RESUME_REVIEW_SPEND (default ON) When off, /api/resume-reviews returns 503
Auto-screen-on-apply AiScreeningConfig + system config global_auto_screen_on_apply Defaults to off unless per-user/global/source enables it
LLM gateway LLM calls are performed inside mr-hire-backend, not this repo See AI platform / LLM gateway

The legacy ResumeAnalysisService constructs an https.Agent({ rejectUnauthorized: false }) and a 120s axios timeout against MR_HIRE_BACKEND_URL/api/evaluate. The live worker uses a 600s timeout on the screening call and 30s on call trigger.


Status lifecycles

ScreeningResult.screeningStatus

stateDiagram-v2
  [*] --> pending
  pending --> resume_screening: worker starts Stage 1
  resume_screening --> resume_screened: scores stored
  resume_screened --> completed: resume gate fail auto reject
  resume_screened --> completed: aiCallEnabled false compute now
  resume_screened --> call_scheduled: ai-call job starts
  call_scheduled --> call_completed: call ended and analyzed
  call_completed --> completed: final score computed
  call_scheduled --> completed: gate skips call
  resume_screening --> failed: error or name mismatch
  call_scheduled --> failed: call stage error
  completed --> [*]
  failed --> [*]

ResumeAnalysis.status (legacy)

stateDiagram-v2
  [*] --> pending
  pending --> processing: processResumeAnalysis starts
  processing --> completed: AI evaluate returns
  processing --> failed: error stored in errorMessage
  completed --> [*]
  failed --> [*]

ResumeReviewRequestStatus

stateDiagram-v2
  [*] --> pending: token spent and request created
  pending --> under_review: reviewer picks it up
  under_review --> completed: review file uploaded
  pending --> rejected: ops rejects
  under_review --> rejected: ops rejects
  completed --> [*]
  rejected --> [*]

JobApplication.status driven by screening

stateDiagram-v2
  [*] --> resume_received
  resume_received --> resume_parsed: scored quizAutoInvite off
  resume_received --> quiz_invited: quiz email sent
  resume_received --> rejected: resume gate fail
  quiz_invited --> quiz_completed: candidate submits quiz
  quiz_completed --> voice_interview_scheduled: ai-call queued
  voice_interview_scheduled --> voice_interview_completed: call ended
  voice_interview_completed --> shortlisted: final at or above shortlist
  voice_interview_completed --> rejected: final at or below reject
  voice_interview_completed --> scoring_complete: in between needs HR
  scoring_complete --> talent_pool: added to pool

Edge cases, limits & gotchas

  • Legacy write path is dead code. ResumeAnalysisService.processResumeAnalysis (the /api/evaluate Tier A/B/C path that writes resume_analyses) and createAnalysisRecord are not invoked anywhere — the worker handles resume-analysis jobs by writing ScreeningResult instead. The ResumeAnalysis read endpoints under /api/admin/analysis* still work but will only return rows from older data. Treat ScreeningResult as the source of truth for the live pipeline.

  • resume-analysis and full-screening are the same code path in the worker (Stage 1). The screen endpoint passes the caller's authToken, but the worker always overrides it with the static INTERNAL_SERVICE_TOKEN for the backend-to-backend call (user JWTs are signed with a different secret and would 401).

  • Re-screening resets everything. When a ScreeningResult already exists, Stage 1 nulls out every score/call/quiz field, expires existing CandidateQuizResponse rows, then deletes them, so a re-run starts clean. A retry of just the call uses POST /retry-stage with stage: ai-call.

  • Config fallback chain. Both the worker (getScreeningConfigForJob) and the controller (getConfig) resolve job-specific → global (isGlobal=true, belongsTo IS NULL for the worker; user-global then system-global for the controller) → hardcoded DEFAULT_CONFIG. The chosen config is snapshotted onto ScreeningResult.screeningConfig so later stages stay consistent even if the config changes mid-pipeline.

  • Weights must sum to 100. validateWeights rejects configs where weightResume + weightQuiz + weightVoiceInterview != 100. At scoring time, weights are re-normalized over only the stages that actually produced a score, so a resume-only candidate gets 100% resume weight.

  • Idempotency on calls. Stage 2 guards against duplicate dials with callProcessedAt, communicationScore, and callStatus === in_progress. The webhook path and the delayed-check path both respect callProcessedAt.

  • Name mismatch deletes the application. A failed name check is destructive: it emails the candidate and HR, marks screening failed, and appRepo.delete(applicationId). The matching heuristic uses normalization, substring containment, token overlap, and a resume-filename hint — it can produce false positives/negatives on unusual names.

  • Business-hours gating for calls is disabled (if (false) block) — calls currently fire 24/7 in dev. There is a TODO: Re-enable for production note. IST offset logic remains in place.

  • No x-platform branching here. Unlike some MAS domains, the screening worker and controllers do not switch on the multi-tenant x-platform header; tenancy is implicit via the belongsTo HR owner derived from the JobPost.

  • Auth nuance. resumeAnalysis.routes allow ADMIN or EXTERNAL_HR; aiScreeningConfig requires full adminMiddleware; jobPost screening-config and resume-reviews only require a valid JWT (authMiddleware) — per-user scoping is enforced in-handler via req.user.id / belongsTo.

  • AiScreeningConfig vs JobScreeningConfig are different things. AiScreeningConfig only holds the boolean toggles autoScreenEnabled / autoScreenOnApply per HR user; the actual thresholds and weights live in JobScreeningConfig.