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:
-
AI screening pipeline (the live system). Driven by the
resumeAnalysisQueueBullMQ queue andsrc/workers/resumeAnalysis.worker.ts. Each job carries aJobApplicationid and atype(resume-analysis/full-screening/ai-call/ai-call-check). The worker fetches the resume from S3, callsmr-hire-backendfor parsing + scoring + quiz curation, writes everything to aScreeningResultrow, gates the candidate through configurable thresholds, and computes a weighted final score. Results feed the ATS, the talent pool, and the workflow-automation engine. -
Legacy basic resume analysis.
ResumeAnalysisService+ theResumeAnalysisentity provide a simpler "Tier A/B/C" analysis (callsmr-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. -
Student resume review (token spend).
ResumeReviewServicelets a paid student spend oneresumeTokento 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_received→resume_parsed→quiz_invited→quiz_completed→voice_interview_scheduled→voice_interview_completed→scoring_complete→shortlisted/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/statisticsis registered afterGET /analysis/:analysisId; the:analysisIdroute does not matchstatisticsbecause/analysis/statisticsis a distinct two-segment path matched by the explicit route. The list route/analysisis 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-checkare castas anyat enqueue sites — they are valid worker types but are not in the publishedResumeAnalysisJobDataunion insrc/types/ResumeQueue.types.ts.
Enqueue sites
mrHire.controller.ts— auto-screen on apply, and on bulk import.candidateQuiz.controller.ts—ai-callafter quiz completion + threshold pass.voiceInterview.controller.ts—ai-callretry on webhook call failure (business-hours-adjusted delay).resumeAnalysis.routes.ts—full-screeningon manual screen,ai-callon retry-stage.- The worker itself re-enqueues
ai-call(business hours, currently disabled) andai-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/evaluateTier A/B/C path that writesresume_analyses) andcreateAnalysisRecordare not invoked anywhere — the worker handlesresume-analysisjobs by writingScreeningResultinstead. TheResumeAnalysisread endpoints under/api/admin/analysis*still work but will only return rows from older data. TreatScreeningResultas the source of truth for the live pipeline. -
resume-analysisandfull-screeningare the same code path in the worker (Stage 1). Thescreenendpoint passes the caller'sauthToken, but the worker always overrides it with the staticINTERNAL_SERVICE_TOKENfor the backend-to-backend call (user JWTs are signed with a different secret and would 401). -
Re-screening resets everything. When a
ScreeningResultalready exists, Stage 1 nulls out every score/call/quiz field, expires existingCandidateQuizResponserows, then deletes them, so a re-run starts clean. A retry of just the call usesPOST /retry-stagewithstage: ai-call. -
Config fallback chain. Both the worker (
getScreeningConfigForJob) and the controller (getConfig) resolve job-specific → global (isGlobal=true,belongsTo IS NULLfor the worker; user-global then system-global for the controller) → hardcodedDEFAULT_CONFIG. The chosen config is snapshotted ontoScreeningResult.screeningConfigso later stages stay consistent even if the config changes mid-pipeline. -
Weights must sum to 100.
validateWeightsrejects configs whereweightResume + 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, andcallStatus === in_progress. The webhook path and the delayed-check path both respectcallProcessedAt. -
Name mismatch deletes the application. A failed name check is destructive: it emails the candidate and HR, marks screening
failed, andappRepo.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 aTODO: Re-enable for productionnote. IST offset logic remains in place. -
No
x-platformbranching here. Unlike some MAS domains, the screening worker and controllers do not switch on the multi-tenantx-platformheader; tenancy is implicit via thebelongsToHR owner derived from theJobPost. -
Auth nuance.
resumeAnalysis.routesallowADMINorEXTERNAL_HR;aiScreeningConfigrequires fulladminMiddleware;jobPostscreening-config andresume-reviewsonly require a valid JWT (authMiddleware) — per-user scoping is enforced in-handler viareq.user.id/belongsTo. -
AiScreeningConfigvsJobScreeningConfigare different things.AiScreeningConfigonly holds the boolean togglesautoScreenEnabled/autoScreenOnApplyper HR user; the actual thresholds and weights live inJobScreeningConfig.