§ unswayed-backend · API contract & docs
Lexi AI — resume tailoring & ATS (Phase 14, UN-110)
Lexi AI — resume tailoring & ATS optimization (Phase 14, UN-110)
T-14.2 is the first applicant-facing career tool in the new src/lexi-ai/
module. Where Lenux (Phase 13) is the employer AI suite, Lexi is the
applicant assistant on the same /api/v1/* surface: bare camelCase bodies,
the verbatim 429 body, per-user rate limits, applicant-only, gated by
per-user feature flags. This slice gives a candidate two things: a
deterministic ATS score of a resume against a job, and an LLM-driven
tailoring diff they can selectively apply.
The four endpoints
All live on ResumeTailoringController (@Controller('v1')) behind the standard
Lexi guard stack: JwtAuthGuard, AccountStatusGuard, UserTypeGuard, LenuxRateLimitGuard, LexiFeatureGuard + @RequireUserType(applicant) +
@BareResponse(). The path {userId} must equal the caller
(LexiUserContextService.resolveSelfApplicant), and the resume must belong to
the caller's Applicant (requireResume, 404 otherwise).
| Method + path | Limit | Feature flag | What it does |
|---|---|---|---|
POST users/{userId}/resume/ats-scan |
30/hr/user | atsScan |
Score a resume vs a JD (200) |
POST users/{userId}/resume/tailor |
20/hr/user | resumeTailoring |
Generate an LLM diff (201, never applied) |
GET users/{userId}/resume/tailor/history?resumeId= |
60/min/user | resumeTailoring |
Versioned runs, createdAt desc, paginated |
POST users/{userId}/resume/tailor/apply |
20/hr/user | resumeTailoring |
Apply accepted changes → mutate the resume (200) |
The JD comes from either a platform job (jobPostingId → EmployerJob) or
pasted jobDescriptionText. The DTO enforces exactly one (a custom
OneJdSourceConstraint XOR validator) — neither/both → 422.
How the ATS score is computed (pure, no LLM)
AtsScoringService is deterministic and offline — it never touches the AI
port. The score is a weighted blend of four 0–100 components:
| Component | Weight | How |
|---|---|---|
keywordMatch |
0.45 | weighted overlap of JD skills present in resumeData.skills (case-insensitive); empty JD skills → neutral 100 |
sectionCompleteness |
0.25 | share of Experience / Education / Skills / Summary present in resumeData |
formatting |
0.20 | heuristic over standard section presence (base 40 + up to 60) |
readability |
0.10 | Flesch Reading Ease over the resume's free-form prose, clamped to 0–100 |
The Flesch helper (206.835 − 1.015·(words/sentences) − 84.6·(syllables/words))
is replicated from the Lenux jd-optimization/readability.service.ts so the
slice stays self-contained (the Lenux version is a method on an @Injectable,
not an exported pure function).
improvementAreas is the top-3 plain-language tips, ranked by weighted gap
(100 − component) × weight — so a poor keyword match (highest weight) surfaces
first. Only components with a real gap (>0) are listed.
Tailoring is a diff, not a mutation
tailor computes the ATS score, asks the LLM (gpt-4o-mini, temperature 0.2,
responseFormat:'json') for a STRICT-JSON changes[] array — each
{id, section, type:'add'|'rewrite', before?, after|content} — parses + retries
once on malformed output (then 422), assigns each change a stable
server-side id (chg_1, chg_2, …), projects the after score by applying the
diff to a copy of the resume, and persists a tailor run. It never writes
ApplicantResume.resumeData. An unconfigured provider surfaces a
LexiUnavailableError → 503.
apply is the only path that mutates the resume. It validates the run belongs
to the caller (404), that every acceptedChangeId references a change in the run
(else 422), applies only the accepted changes to resumeData (skills changes
add new skills; experience rewrites replace a matching bullet or append one),
persists the resume, and recomputes the ATS score for the response
{resumeId, appliedCount, newAtsScore}.
Persistence & versioning
Every scan and tailor writes a versioned ResumeTailoringRun
(kind ∈ ats_scan | tailor), with atsScore, breakdown,
improvementAreas, optional changes + atsScoreAfter. version is monotonic
per (userId, resumeId). An ats_scan also stamps
ApplicantResume.lastAtsScanAt. A resume with no parsed content (every section
empty) is rejected 422 before scanning.
Reuse surface for the chat slice (UN-114)
ResumeTailoringModule exports ResumeTailoringService. The Lexi chat slice
reuses its public methods to execute confirmed pending actions:
atsScan(userId, applicant, resumeId, {jobPostingId?|jobDescriptionText?})tailor(userId, applicant, resumeId, {jobPostingId?|jobDescriptionText?})apply(userId, applicant, tailoringRunId, acceptedChangeIds[])history(userId, applicant, resumeId, page?, limit?)
Files
src/lexi-ai/resume-tailoring/ats-scoring.service.ts— pure ATS math + Fleschsrc/lexi-ai/resume-tailoring/resume-tailoring.service.ts— orchestration, JD resolution, LLM diff, applysrc/lexi-ai/resume-tailoring/resume-tailoring.controller.ts— the four endpoints + 503 mappingsrc/lexi-ai/resume-tailoring/dto/resume-tailoring.dto.ts— bodies/queries + the XOR JD-source validatorsrc/lexi-ai/resume-tailoring/resume-tailoring.types.ts—ResumeDatacoercion, breakdown/change shapessrc/lexi-ai/resume-tailoring/resume-tailoring.module.ts— importsLexiCommonModule, bindsAiCompletionPortvia theopenaiConfigfactory, exports the servicetest/lexi-resume-tailoring.e2e-spec.ts— 25 e2e cases (overrideAiCompletionPort)
Testing
TDD throughout; 55 unit tests + 25 e2e. Unit coverage on the slice is 100%
lines/functions, ~86% branches. The e2e boots AppModule + ResumeTailoringModule
with a scripted fake AiCompletionPort and exercises every endpoint, ownership
(403 cross-user, 404 foreign resume), feature-flag 403s, the 429 body, AI
retry/422/503, and the full tailor → apply round-trip (asserting the resume is
unchanged after tailor and updated only with accepted changes after apply).