CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lexi AI — resume tailoring & ATS (Phase 14, UN-110)

updated 2026-06-17

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 (jobPostingIdEmployerJob) 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 LexiUnavailableError503.

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 (kindats_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 + Flesch
  • src/lexi-ai/resume-tailoring/resume-tailoring.service.ts — orchestration, JD resolution, LLM diff, apply
  • src/lexi-ai/resume-tailoring/resume-tailoring.controller.ts — the four endpoints + 503 mapping
  • src/lexi-ai/resume-tailoring/dto/resume-tailoring.dto.ts — bodies/queries + the XOR JD-source validator
  • src/lexi-ai/resume-tailoring/resume-tailoring.types.tsResumeData coercion, breakdown/change shapes
  • src/lexi-ai/resume-tailoring/resume-tailoring.module.ts — imports LexiCommonModule, binds AiCompletionPort via the openaiConfig factory, exports the service
  • test/lexi-resume-tailoring.e2e-spec.ts — 25 e2e cases (override AiCompletionPort)

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).